├── .gitignore ├── LICENSE ├── README.md ├── assets ├── _old │ ├── icon.png │ ├── icon.svg │ ├── icon2.png │ └── icon2.svg ├── chrome-web-store.png ├── demo.png ├── icon.png ├── icon.svg ├── landing-hero.png ├── screenshot-arxiv.png ├── screenshot-fountain.png ├── screenshot-gemini.png ├── screenshot-gigi.png ├── screenshot-github.png ├── screenshot-nbb.png └── web_accessible_resources.js ├── package.json ├── postcss.config.js ├── releases └── web-content-conversation.zip ├── src ├── background │ ├── index.ts │ └── messages │ │ ├── sidepanel │ │ └── open-panel.ts │ │ ├── storage │ │ ├── get-settings.ts │ │ ├── get-user.ts │ │ ├── set-settings.ts │ │ └── set-user.ts │ │ └── tabs │ │ └── query-active.ts ├── constants │ ├── chrome.ts │ ├── files.ts │ ├── global.ts │ ├── nostr.ts │ ├── popup.ts │ ├── radix.ts │ └── settings.ts ├── contents │ ├── content.tsx │ └── index.ts ├── lib │ ├── chrome │ │ └── index.ts │ ├── nostr.wine │ │ └── index.ts │ ├── nostr │ │ ├── NostrClass.ts │ │ ├── NostrProvider.tsx │ │ ├── createNostrKey.ts │ │ ├── getUserFromStorage.ts │ │ ├── resolvers.ts │ │ ├── useNotes.ts │ │ ├── useNotesMutation.ts │ │ ├── useNotesReactions.ts │ │ ├── useNotesReactionsMutation.ts │ │ ├── useNotesReplies.ts │ │ └── useUserProfile.ts │ ├── parse-url │ │ └── index.ts │ ├── utils │ │ └── getDateTimeSince.ts │ ├── w3 │ │ └── getShortenUrl.ts │ └── zustand │ │ ├── app.ts │ │ ├── nostr.ts │ │ ├── popup.ts │ │ └── sidepanel.ts ├── pages │ ├── _app.tsx │ └── index.tsx ├── popup │ └── index.tsx ├── sidepanel │ └── index.tsx ├── styles.css ├── types │ ├── Settings.ts │ ├── app │ │ ├── AppScreens.ts │ │ ├── NotesView.ts │ │ └── PopupScreens.ts │ ├── nostr │ │ ├── Note.ts │ │ └── UserIdentifier.ts │ └── sidepanel │ │ └── SidePanelScreens.ts └── ui │ ├── common │ ├── Link.tsx │ ├── Loading.tsx │ ├── Note │ │ ├── NoteContent.tsx │ │ ├── NoteControls.tsx │ │ ├── NoteEditor.tsx │ │ ├── NoteImage.tsx │ │ ├── NoteLink.tsx │ │ ├── NoteMeta.tsx │ │ ├── NoteTextarea.tsx │ │ ├── Replies.tsx │ │ ├── ReplyBox.tsx │ │ └── index.tsx │ ├── Toast.tsx │ ├── UI.tsx │ ├── UserAvatar.tsx │ └── UserName.tsx │ ├── content │ ├── Drawer │ │ ├── Controls.tsx │ │ └── index.tsx │ ├── Notes │ │ ├── NewNote.tsx │ │ ├── Note │ │ │ ├── NoteContent.tsx │ │ │ ├── NoteControls.tsx │ │ │ ├── NoteImage.tsx │ │ │ ├── NoteLink.tsx │ │ │ ├── NoteMeta.tsx │ │ │ ├── Replies.tsx │ │ │ └── index.tsx │ │ ├── NoteTextarea.tsx │ │ ├── NotesList.tsx │ │ └── ReplyBox.tsx │ ├── PageCard │ │ ├── TopBar.tsx │ │ └── index.tsx │ └── index.tsx │ ├── landing │ ├── Demo │ │ └── index.tsx │ ├── Header │ │ └── index.tsx │ ├── Hero │ │ ├── index.tsx │ │ └── index_old.tsx │ └── index.tsx │ ├── popup │ ├── About │ │ └── index.tsx │ ├── Account │ │ ├── DisplayName.tsx │ │ ├── Npub.tsx │ │ ├── Nsec.tsx │ │ ├── ProfileImage.tsx │ │ ├── Relay.tsx │ │ ├── Signout.tsx │ │ ├── Welcome │ │ │ ├── NostrLogin.tsx │ │ │ ├── NsecCreate.tsx │ │ │ ├── NsecSignIn.tsx │ │ │ └── index.tsx │ │ └── index.tsx │ ├── Appearance │ │ ├── IconDisplay.tsx │ │ ├── IconOffset.tsx │ │ ├── IconPlacement.tsx │ │ ├── WindowSize.tsx │ │ ├── WindowTransparency.tsx │ │ └── index.tsx │ ├── Posts │ │ ├── CreditApp.tsx │ │ ├── NostrIcons.tsx │ │ ├── ShortenUrl.tsx │ │ ├── SortBy.tsx │ │ ├── WineSearch.tsx │ │ └── index.tsx │ ├── common │ │ └── Row.tsx │ └── index.tsx │ └── sidebar │ ├── Feed │ ├── FeedContent.tsx │ └── FeedTabContainer.tsx │ ├── Menu │ └── index.tsx │ ├── TopBar.tsx │ └── index.tsx ├── tailwind.config.js └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | /node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | 12 | # misc 13 | .DS_Store 14 | *.pem 15 | 16 | # debug 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | .pnpm-debug.log* 21 | 22 | # local env files 23 | .env*.local 24 | 25 | out/ 26 | build/ 27 | dist/ 28 | 29 | # plasmo 30 | .plasmo 31 | 32 | # typescript 33 | .tsbuildinfo 34 | 35 | yarn.lock 36 | package-lock.json 37 | .next 38 | next-env.d.ts -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Satcom 2 | 3 | Introducing a collaborative layer to internet, enhances online discussions and redefines the browsing experience. 4 | 5 | By integrating web content with online discussions, it enables collaborative knowledge sharing, revolutionizing how we engage with information online. 6 | 7 | [![](https://raw.githubusercontent.com/jinglescode/web-content-conversation/main/assets/chrome-web-store.png)](https://chromewebstore.google.com/detail/satcom/lhoejonhkpkgnhaamjcplefkkomlldgi) 8 | 9 | | Explore a new dimension of interaction | | 10 | |---|---| 11 | | ![](https://raw.githubusercontent.com/jinglescode/web-content-conversation/main/assets/screenshot-arxiv.png) | Effortlessly share ideas directly within the context of the web content you're exploring. | 12 | | ![](https://raw.githubusercontent.com/jinglescode/web-content-conversation/main/assets/screenshot-gemini.png) | Start a discussion on any web page, and invite others to join in. | 13 | | ![](https://raw.githubusercontent.com/jinglescode/web-content-conversation/main/assets/screenshot-github.png) | Pose questions directly to any projects or articles. | 14 | 15 | ## Developer 16 | 17 | You can load the extension locally on your browser to try it out. These steps will load the extension locally on your browser. 18 | 19 | 1. Download latest release from [GitHub](https://github.com/jinglescode/web-content-conversation/raw/main/releases/web-content-conversation.zip) 20 | 2. Unzip the zip file 21 | 3. From Chrome browser, go to `chrome://extensions/` 22 | 4. Enable Developer mode 23 | 5. Click on `Load unpacked` and select the unzipped folder 24 | 6. Go to any website and you will see an icon on the bottom right corner of the browser 25 | 26 | ### Folder structure 27 | 28 | Here is the folder structure of the source code. It may be helpful to understand the source code, and maybe outdated as the project is actively developed. 29 | 30 | ``` 31 | . 32 | ├── assets # stores images and other assets 33 | ├── build # build:plasmo will output the build files here 34 | ├── releases # stores the latest release 35 | ├── src # source code 36 | │ ├── background # extension background scripts, handle browser tasks 37 | │ ├── constants # variables used throughout the extension 38 | │ ├── contents # extension content scripts, injects into web pages 39 | │ ├── lib # libraries and common functions 40 | │ │ ├── chrome # chrome functions 41 | │ │ ├── nostr # nostr react hooks, utils functions and ndk 42 | │ │ ├── utils # common functions 43 | │ │ ├── w3 # url shorten integration 44 | │ │ ├── zustand # state management 45 | │ ├── pages # satcom.app, not used in extension 46 | │ ├── popup # extension popup, for settings 47 | │ ├── types # define typescript types 48 | │ ├── ui # all ui components 49 | │ │ ├── common # ui components used in content and popup 50 | │ │ ├── content # ui for content (injects into web pages) 51 | │ │ ├── landing # ui for landing (satcom.app) 52 | │ │ ├── popup # ui for popup (settings) 53 | ``` 54 | -------------------------------------------------------------------------------- /assets/_old/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jinglescode/web-content-conversation/43d0b28bf660aa7c14dd4a9e80a78d0e33a0172e/assets/_old/icon.png -------------------------------------------------------------------------------- /assets/_old/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /assets/_old/icon2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jinglescode/web-content-conversation/43d0b28bf660aa7c14dd4a9e80a78d0e33a0172e/assets/_old/icon2.png -------------------------------------------------------------------------------- /assets/_old/icon2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /assets/chrome-web-store.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jinglescode/web-content-conversation/43d0b28bf660aa7c14dd4a9e80a78d0e33a0172e/assets/chrome-web-store.png -------------------------------------------------------------------------------- /assets/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jinglescode/web-content-conversation/43d0b28bf660aa7c14dd4a9e80a78d0e33a0172e/assets/demo.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jinglescode/web-content-conversation/43d0b28bf660aa7c14dd4a9e80a78d0e33a0172e/assets/icon.png -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /assets/landing-hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jinglescode/web-content-conversation/43d0b28bf660aa7c14dd4a9e80a78d0e33a0172e/assets/landing-hero.png -------------------------------------------------------------------------------- /assets/screenshot-arxiv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jinglescode/web-content-conversation/43d0b28bf660aa7c14dd4a9e80a78d0e33a0172e/assets/screenshot-arxiv.png -------------------------------------------------------------------------------- /assets/screenshot-fountain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jinglescode/web-content-conversation/43d0b28bf660aa7c14dd4a9e80a78d0e33a0172e/assets/screenshot-fountain.png -------------------------------------------------------------------------------- /assets/screenshot-gemini.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jinglescode/web-content-conversation/43d0b28bf660aa7c14dd4a9e80a78d0e33a0172e/assets/screenshot-gemini.png -------------------------------------------------------------------------------- /assets/screenshot-gigi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jinglescode/web-content-conversation/43d0b28bf660aa7c14dd4a9e80a78d0e33a0172e/assets/screenshot-gigi.png -------------------------------------------------------------------------------- /assets/screenshot-github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jinglescode/web-content-conversation/43d0b28bf660aa7c14dd4a9e80a78d0e33a0172e/assets/screenshot-github.png -------------------------------------------------------------------------------- /assets/screenshot-nbb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jinglescode/web-content-conversation/43d0b28bf660aa7c14dd4a9e80a78d0e33a0172e/assets/screenshot-nbb.png -------------------------------------------------------------------------------- /assets/web_accessible_resources.js: -------------------------------------------------------------------------------- 1 | window.postMessage( 2 | { type: "FROM_PAGE", text: JSON.stringify(window.nostr) }, 3 | "*" 4 | ); 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "satcom", 3 | "displayName": "Satcom - Collaborative layer for the Internet", 4 | "version": "0.1.6", 5 | "description": "Adding collaborative layer to your Internet browsing experience.", 6 | "author": "Jingles (me@jingles.me)", 7 | "scripts": { 8 | "start": "next start", 9 | "dev:plasmo": "plasmo dev", 10 | "dev:next": "next dev", 11 | "build:plasmo": "plasmo build", 12 | "build:next": "next build", 13 | "build:firefox": "plasmo build --target=firefox-mv2" 14 | }, 15 | "dependencies": { 16 | "@headlessui/react": "^1.7.18", 17 | "@heroicons/react": "^2.1.1", 18 | "@nostr-dev-kit/ndk": "^2.3.3", 19 | "@plasmohq/messaging": "^0.6.1", 20 | "@plasmohq/storage": "^1.9.0", 21 | "@radix-ui/react-icons": "^1.3.0", 22 | "@radix-ui/themes": "^2.0.3", 23 | "@tanstack/react-query": "^5.17.19", 24 | "lodash": "^4.17.21", 25 | "next": "14.0.2", 26 | "nostr-login": "^1.0.11", 27 | "nostr-tools": "^2.1.4", 28 | "parse-path": "^7.0.0", 29 | "plasmo": "0.84.2", 30 | "react": "18.2.0", 31 | "react-dom": "18.2.0", 32 | "react-string-replace": "^1.1.1", 33 | "string-strip-html": "8.4.0", 34 | "zustand": "^4.5.0" 35 | }, 36 | "devDependencies": { 37 | "@types/chrome": "0.0.251", 38 | "@types/node": "20.9.0", 39 | "@types/react": "18.2.37", 40 | "@types/react-dom": "18.2.15", 41 | "autoprefixer": "^10.4.17", 42 | "postcss": "^8.4.33", 43 | "prettier": "3.0.3", 44 | "tailwindcss": "^3.4.1", 45 | "typescript": "5.2.2", 46 | "vm-browserify": "^1.1.2" 47 | }, 48 | "manifest": { 49 | "host_permissions": [ 50 | "http://*/*", 51 | "https://*/*" 52 | ], 53 | "permissions": [ 54 | "activeTab", 55 | "tabs" 56 | ], 57 | "web_accessible_resources": [ 58 | { 59 | "resources": [ 60 | "assets/icon.png", 61 | "assets/icon.svg", 62 | "assets/web_accessible_resources.js" 63 | ], 64 | "matches": [ 65 | "" 66 | ] 67 | } 68 | ] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /releases/web-content-conversation.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jinglescode/web-content-conversation/43d0b28bf660aa7c14dd4a9e80a78d0e33a0172e/releases/web-content-conversation.zip -------------------------------------------------------------------------------- /src/background/index.ts: -------------------------------------------------------------------------------- 1 | import { EXTENSION_KEY } from "~constants/chrome"; 2 | 3 | chrome.tabs.onUpdated.addListener(function (tabId, changeInfo, tab) { 4 | if (changeInfo.title) { 5 | chrome.tabs.sendMessage(tabId, { 6 | ext: EXTENSION_KEY, 7 | type: "urlChanged", 8 | params: { url: tab.url, title: tab.title }, 9 | }); 10 | } 11 | }); 12 | 13 | chrome.sidePanel 14 | .setPanelBehavior({ openPanelOnActionClick: true }) 15 | .catch((error) => console.error(error)); 16 | -------------------------------------------------------------------------------- /src/background/messages/sidepanel/open-panel.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging"; 2 | 3 | const handler: PlasmoMessaging.MessageHandler = async (req, res) => { 4 | await chrome.sidePanel.open({ tabId: req.sender.tab.id }); 5 | await chrome.sidePanel.setOptions({ 6 | tabId: req.sender.tab.id, 7 | path: "sidepanel.html", 8 | enabled: true, 9 | }); 10 | 11 | res.send(true); 12 | }; 13 | 14 | export default handler; 15 | -------------------------------------------------------------------------------- /src/background/messages/storage/get-settings.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging" 2 | import { Storage } from "@plasmohq/storage" 3 | 4 | const handler: PlasmoMessaging.MessageHandler = async (req, res) => { 5 | const storage = new Storage() 6 | const data = await storage.get("settings") 7 | res.send(data) 8 | } 9 | 10 | export default handler 11 | -------------------------------------------------------------------------------- /src/background/messages/storage/get-user.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging" 2 | import { Storage } from "@plasmohq/storage" 3 | 4 | const handler: PlasmoMessaging.MessageHandler = async (req, res) => { 5 | const storage = new Storage() 6 | const data = await storage.get("user") 7 | res.send(data) 8 | } 9 | 10 | export default handler 11 | -------------------------------------------------------------------------------- /src/background/messages/storage/set-settings.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging" 2 | import { Storage } from "@plasmohq/storage" 3 | 4 | const handler: PlasmoMessaging.MessageHandler = async (req, res) => { 5 | const storage = new Storage() 6 | await storage.set("settings", req.body) 7 | res.send(true) 8 | } 9 | 10 | export default handler 11 | -------------------------------------------------------------------------------- /src/background/messages/storage/set-user.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging" 2 | import { Storage } from "@plasmohq/storage" 3 | 4 | const handler: PlasmoMessaging.MessageHandler = async (req, res) => { 5 | const storage = new Storage() 6 | await storage.set("user", req.body) 7 | res.send(true) 8 | } 9 | 10 | export default handler 11 | -------------------------------------------------------------------------------- /src/background/messages/tabs/query-active.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoMessaging } from "@plasmohq/messaging"; 2 | 3 | const handler: PlasmoMessaging.MessageHandler = async (req, res) => { 4 | try { 5 | chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { 6 | if (tabs && tabs.length > 0) { 7 | res.send(tabs[0]); 8 | } 9 | res.send(undefined); 10 | }); 11 | } catch (e) { 12 | res.send(undefined); 13 | } 14 | }; 15 | 16 | export default handler; 17 | -------------------------------------------------------------------------------- /src/constants/chrome.ts: -------------------------------------------------------------------------------- 1 | export const EXTENSION_KEY = "web-annotation"; 2 | -------------------------------------------------------------------------------- /src/constants/files.ts: -------------------------------------------------------------------------------- 1 | export const IMAGES = ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"]; 2 | 3 | export const VIDEOS = [ 4 | "mp4", 5 | "mov", 6 | "webm", 7 | "wmv", 8 | "flv", 9 | "mts", 10 | "avi", 11 | "ogv", 12 | "mkv", 13 | "m3u8", 14 | ]; 15 | 16 | export const AUDIOS = ["mp3", "ogg", "wav"]; 17 | -------------------------------------------------------------------------------- /src/constants/global.ts: -------------------------------------------------------------------------------- 1 | export const APP_NAME = "Satcom"; 2 | export const APP_DESC = 3 | "Adding collaborative layer to your Internet browsing experience."; 4 | 5 | export const HOMEPAGE_URL = "https://satcom.app/"; 6 | export const GITHUB_URL = 7 | "https://github.com/jinglescode/web-content-conversation"; 8 | export const DONATE_URL = "https://getalby.com/p/jinglescode"; 9 | 10 | export const APP_CREDIT = 11 | "Posted via 🛰️ #Satcom (https://satcom.app)"; 12 | -------------------------------------------------------------------------------- /src/constants/nostr.ts: -------------------------------------------------------------------------------- 1 | export const EXPLICIT_RELAY_URLS = [ 2 | "wss://relay.damus.io", 3 | "wss://relay.nostr.band", 4 | "wss://nos.lol", 5 | "wss://nostr.mutinywallet.com", 6 | "wss://relay.nsec.app", 7 | ]; 8 | 9 | export const STALE_TIME = 1000 * 60 * 60 * 24; 10 | 11 | export const RELAY = "wss://relay.damus.io"; 12 | 13 | export const NOSTR_REDIRECT_URL = "https://njump.me/"; 14 | 15 | export const NOSTR_EVENTS = [ 16 | "@nevent1", 17 | "@note1", 18 | "@nostr:note1", 19 | "@nostr:nevent1", 20 | "nostr:note1", 21 | "note1", 22 | "nostr:nevent1", 23 | "nevent1", 24 | "Nostr:note1", 25 | "Nostr:nevent1", 26 | ]; 27 | 28 | export const NOSTR_MENTIONS = [ 29 | "@npub1", 30 | "nostr:npub1", 31 | "nostr:nprofile1", 32 | "nostr:naddr1", 33 | "npub1", 34 | "nprofile1", 35 | "naddr1", 36 | "Nostr:npub1", 37 | "Nostr:nprofile1", 38 | "Nostr:naddre1", 39 | ]; 40 | -------------------------------------------------------------------------------- /src/constants/popup.ts: -------------------------------------------------------------------------------- 1 | export const POPUP_HEIGHT = "400px"; 2 | -------------------------------------------------------------------------------- /src/constants/radix.ts: -------------------------------------------------------------------------------- 1 | export const ACCENT_COLOR = "gray" 2 | export const HIGHLIGHT_COLOR = "purple" 3 | -------------------------------------------------------------------------------- /src/constants/settings.ts: -------------------------------------------------------------------------------- 1 | import { EXPLICIT_RELAY_URLS } from "~constants/nostr"; 2 | import type { Settings } from "~types/Settings"; 3 | 4 | export const DEFAULT_SETTINGS: Settings = { 5 | appearance: { 6 | iconDisplay: true, 7 | iconPlacement: "bottom", 8 | windowSize: "large", 9 | iconOffset: 0, 10 | windowTransparency: true, 11 | }, 12 | nostr: { 13 | relays: EXPLICIT_RELAY_URLS, 14 | }, 15 | notes: { 16 | shorten: false, 17 | credit: true, 18 | sortBy: "time", 19 | nostrIcons: true, 20 | wineSearch: false, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/contents/content.tsx: -------------------------------------------------------------------------------- 1 | import cssText from "data-text:@radix-ui/themes/styles.css" 2 | import cssText2 from "data-text:styles.css" 3 | import UiContent from "~ui/content" 4 | 5 | export const getStyle = () => { 6 | const style = document.createElement("style") 7 | style.textContent = cssText 8 | style.textContent += cssText2 9 | return style 10 | } 11 | 12 | export default function IndexContents() { 13 | return 14 | } 15 | -------------------------------------------------------------------------------- /src/contents/index.ts: -------------------------------------------------------------------------------- 1 | import type { PlasmoCSConfig } from "plasmo"; 2 | 3 | import { sendToBackground } from "@plasmohq/messaging"; 4 | import { relay } from "@plasmohq/messaging/relay"; 5 | 6 | export const config: PlasmoCSConfig = { 7 | matches: [""], 8 | all_frames: true, 9 | }; 10 | 11 | relay( 12 | { 13 | name: "tabs/query-active" as const, 14 | }, 15 | async (req) => { 16 | //@ts-ignore 17 | return await sendToBackground(req); 18 | } 19 | ); 20 | 21 | relay( 22 | { 23 | name: "storage/set-user" as const, 24 | }, 25 | async (req) => { 26 | //@ts-ignore 27 | return await sendToBackground(req); 28 | } 29 | ); 30 | 31 | relay( 32 | { 33 | name: "storage/get-user" as const, 34 | }, 35 | async (req) => { 36 | //@ts-ignore 37 | return await sendToBackground(req); 38 | } 39 | ); 40 | 41 | relay( 42 | { 43 | name: "storage/set-settings" as const, 44 | }, 45 | async (req) => { 46 | //@ts-ignore 47 | return await sendToBackground(req); 48 | } 49 | ); 50 | 51 | relay( 52 | { 53 | name: "storage/get-settings" as const, 54 | }, 55 | async (req) => { 56 | //@ts-ignore 57 | return await sendToBackground(req); 58 | } 59 | ); 60 | -------------------------------------------------------------------------------- /src/lib/chrome/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | sendToBackground, 3 | sendToBackgroundViaRelay, 4 | type MessagesMetadata, 5 | } from "@plasmohq/messaging"; 6 | 7 | export async function messageBackgroundRelay( 8 | request: keyof MessagesMetadata, 9 | data = {} 10 | ) { 11 | return await sendToBackgroundViaRelay({ 12 | name: request, 13 | body: data, 14 | }); 15 | } 16 | 17 | export async function messageBackground( 18 | request: keyof MessagesMetadata, 19 | data = {} 20 | ) { 21 | return await sendToBackground({ 22 | name: request, 23 | body: data, 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /src/lib/nostr.wine/index.ts: -------------------------------------------------------------------------------- 1 | export async function wineSearch(keywords: string) { 2 | keywords = keywords.split(" ").join("*"); 3 | 4 | const res = await fetch( 5 | `https://api.nostr.wine/search?query=${keywords}&kind=1&sort=time&limit=100` 6 | ); 7 | if (!res.ok) { 8 | return undefined; 9 | } 10 | 11 | const data = await res.json(); 12 | return data.data; 13 | } -------------------------------------------------------------------------------- /src/lib/nostr/NostrClass.ts: -------------------------------------------------------------------------------- 1 | import NDK, { 2 | NDKEvent, 3 | NDKPrivateKeySigner, 4 | type NDKFilter, 5 | NDKNip46Signer, 6 | } from "@nostr-dev-kit/ndk"; 7 | 8 | import { EXPLICIT_RELAY_URLS } from "~constants/nostr"; 9 | 10 | export default class NostrClass { 11 | ndk: NDK; 12 | signer: NDKPrivateKeySigner; 13 | 14 | constructor({}: {}) {} 15 | 16 | public async init( 17 | signer?: NDKPrivateKeySigner | NDKNip46Signer, 18 | relays = [] 19 | ) { 20 | try { 21 | const ndk = new NDK({ 22 | explicitRelayUrls: EXPLICIT_RELAY_URLS, 23 | signer: signer, 24 | }); 25 | for (const r of relays) ndk.addExplicitRelay(r, undefined); 26 | await ndk.connect(); 27 | this.ndk = ndk; 28 | // if (signer) this.signer = signer; 29 | } catch (e) { 30 | console.error(`[NostrClass] init ${e}`); 31 | } 32 | } 33 | 34 | public async signAndPublishEvent(event: NDKEvent) { 35 | event.ndk = this.ndk; 36 | await this.ndk.publish(event); 37 | } 38 | 39 | public async fetchEvents(filter: NDKFilter) { 40 | return Array.from(await this.ndk.fetchEvents(filter)); 41 | } 42 | 43 | public async signPublishEvent(event: NDKEvent) { 44 | if (this.ndk === undefined) return; 45 | event.ndk = this.ndk; 46 | 47 | await event.sign(); 48 | await event.publish(); 49 | 50 | return event; 51 | } 52 | 53 | public async updateUserName(pubkey: string, name: string) { 54 | if (this.ndk === undefined) return; 55 | 56 | const _user = this.ndk.getUser({ 57 | hexpubkey: pubkey, 58 | }); 59 | 60 | await _user.fetchProfile(); 61 | 62 | if (_user.profile === undefined) { 63 | _user.profile = {}; 64 | } 65 | _user.profile.name = name; 66 | 67 | await _user.publish(); 68 | } 69 | 70 | public async updateProfileImage(pubkey: string, imageUrl: string) { 71 | if (this.ndk === undefined) return; 72 | 73 | const _user = this.ndk.getUser({ 74 | hexpubkey: pubkey, 75 | }); 76 | 77 | await _user.fetchProfile(); 78 | 79 | if (_user.profile === undefined) { 80 | _user.profile = {}; 81 | } 82 | _user.profile.image = imageUrl; 83 | 84 | await _user.publish(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/lib/nostr/NostrProvider.tsx: -------------------------------------------------------------------------------- 1 | import { NDKNip46Signer, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; 2 | import { nip19 } from "nostr-tools"; 3 | import { 4 | createContext, 5 | useContext, 6 | useEffect, 7 | useState, 8 | type PropsWithChildren, 9 | } from "react"; 10 | 11 | import { messageBackground } from "~lib/chrome"; 12 | import { useNostrStore } from "~lib/zustand/nostr"; 13 | import { 14 | UserIdentifierType, 15 | type UserIdentifier, 16 | } from "~types/nostr/UserIdentifier"; 17 | 18 | import { _createNostrKey } from "./createNostrKey"; 19 | import { _getUserFromStorage } from "./getUserFromStorage"; 20 | import NostrClass from "./NostrClass"; 21 | import { bytesToHex } from "@noble/hashes/utils"; 22 | 23 | interface NostrContext { 24 | nostr: NostrClass; 25 | createNostrUser: (name: string) => Promise; 26 | signInNsec: (nsec: string) => Promise; 27 | signInNostrLogin: () => Promise; 28 | } 29 | 30 | const NostrContext = createContext({ 31 | nostr: undefined, 32 | createNostrUser: undefined, 33 | signInNsec: undefined, 34 | signInNostrLogin: undefined, 35 | }); 36 | 37 | export const NostrProvider = ({ children }: PropsWithChildren) => { 38 | const [nostr, setNostr] = useState(undefined); 39 | const setUser = useNostrStore((state) => state.setUser); 40 | 41 | async function initNostrClass() { 42 | try { 43 | const _nostr = new NostrClass({}); 44 | await _nostr.init(); 45 | return _nostr; 46 | } catch (e) { 47 | console.error(`[NostrProvider] initNostrClass - ${e}`); 48 | } 49 | } 50 | 51 | async function createNostrUser(name: string) { 52 | const key = await _createNostrKey(nostr); 53 | const privkey = bytesToHex(key.sk); 54 | 55 | //@ts-ignore 56 | const signer = new NDKPrivateKeySigner(privkey); 57 | 58 | await nostr.init(signer); 59 | 60 | await nostr.updateUserName(key.pk, name); 61 | 62 | const user: UserIdentifier = { 63 | pubkey: key.pk, 64 | nsec: key.nsec, 65 | npub: key.npub, 66 | sk: privkey, 67 | type: UserIdentifierType.PrivateKey, 68 | }; 69 | setUser(user); 70 | 71 | //@ts-ignore 72 | await messageBackground("storage/set-user", user); 73 | } 74 | 75 | async function signInNsec(nsec: string) { 76 | const decodedPrivkey = nip19.decode(nsec); 77 | //@ts-ignore 78 | const privkey = bytesToHex(decodedPrivkey.data); 79 | 80 | const signer = new NDKPrivateKeySigner(privkey); 81 | const _user = await signer.user(); 82 | 83 | if (_user) { 84 | await nostr.init(signer); 85 | 86 | const user: UserIdentifier = { 87 | pubkey: _user.pubkey, 88 | nsec: nsec, 89 | sk: privkey, 90 | npub: _user.npub, 91 | type: UserIdentifierType.PrivateKey, 92 | }; 93 | setUser(user); 94 | 95 | //@ts-ignore 96 | await messageBackground("storage/set-user", user); 97 | 98 | load(); 99 | } else { 100 | setUser(undefined); 101 | } 102 | } 103 | 104 | async function signInNostrLogin() { 105 | let { init, launch } = await import("nostr-login"); 106 | 107 | await init({ bunkers: "nsec.app,nsecbunker.com" }); 108 | await launch({}); 109 | const info = JSON.parse(window.localStorage.getItem("__nostrlogin_nip46")); 110 | 111 | const npub = await nip19.npubEncode(info.pubkey); 112 | 113 | const user: UserIdentifier = { 114 | pubkey: info.pubkey, 115 | sk: info.sk, 116 | npub: npub, 117 | type: UserIdentifierType.Nip46, 118 | relays: info.relay, 119 | }; 120 | setUser(user); 121 | 122 | //@ts-ignore 123 | await messageBackground("storage/set-user", user); 124 | 125 | load(); 126 | } 127 | 128 | async function load() { 129 | const _nostr = await initNostrClass(); 130 | setNostr(_nostr); 131 | try { 132 | const _user = await _getUserFromStorage(_nostr); 133 | if (_user) { 134 | if (_user.type === UserIdentifierType.PrivateKey) { 135 | //@ts-ignore 136 | const signer = new NDKPrivateKeySigner(_user.sk); 137 | await _nostr.init(signer); 138 | } 139 | if (_user.type === UserIdentifierType.Nip46) { 140 | //@ts-ignore 141 | const localSigner = new NDKPrivateKeySigner(_user.sk); 142 | const remoteSigner = new NDKNip46Signer( 143 | _nostr.ndk, 144 | _user.pubkey, 145 | localSigner 146 | ); 147 | remoteSigner.user().then(async (user) => { 148 | await remoteSigner.blockUntilReady(); 149 | await _nostr.init(remoteSigner, _user.relays); 150 | }); 151 | } 152 | setUser(_user); 153 | } else { 154 | setUser(undefined); 155 | } 156 | } catch (e) { 157 | console.error(`[NostrProvider] load - ${e}`); 158 | } 159 | } 160 | 161 | useEffect(() => { 162 | load(); 163 | }, []); 164 | 165 | return ( 166 | 169 | {children} 170 | 171 | ); 172 | }; 173 | 174 | export const useNostr = () => { 175 | const context = useContext(NostrContext); 176 | if (context === undefined) { 177 | throw new Error("Please import NostrProvider to use useNostr() hook"); 178 | } 179 | return context; 180 | }; 181 | -------------------------------------------------------------------------------- /src/lib/nostr/createNostrKey.ts: -------------------------------------------------------------------------------- 1 | import { generateSecretKey, getPublicKey, nip19 } from "nostr-tools" 2 | 3 | import type NostrClass from "./NostrClass" 4 | 5 | export async function _createNostrKey(nostr: NostrClass) { 6 | let sk = generateSecretKey() 7 | let pk = getPublicKey(sk) 8 | let npub = nip19.npubEncode(pk) 9 | let nsec = nip19.nsecEncode(sk) 10 | return { sk, pk, nsec, npub } 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/nostr/getUserFromStorage.ts: -------------------------------------------------------------------------------- 1 | import { messageBackground } from "~lib/chrome"; 2 | import { 3 | UserIdentifierType, 4 | type UserIdentifier, 5 | } from "~types/nostr/UserIdentifier"; 6 | 7 | import type NostrClass from "./NostrClass"; 8 | import { nip19 } from "nostr-tools"; 9 | 10 | export async function _getUserFromStorage(nostr: NostrClass) { 11 | //@ts-ignore 12 | const storageUser = await messageBackground("storage/get-user"); 13 | 14 | if (storageUser) { 15 | const nostrUser = nostr.ndk.getUser({ 16 | hexpubkey: storageUser.pubkey, 17 | }); 18 | await nostrUser.fetchProfile(); 19 | 20 | const user: UserIdentifier = { 21 | pubkey: storageUser.pubkey, 22 | npub: storageUser.npub, 23 | nsec: storageUser.nsec, 24 | sk: storageUser.sk, 25 | type: storageUser.type, 26 | }; 27 | 28 | let updateProfile = false; 29 | 30 | // backward: nsec to sk 31 | if (user.sk == undefined) { 32 | const decodedPrivkey = nip19.decode(user.nsec); 33 | //@ts-ignore 34 | const privkey = bytesToHex(decodedPrivkey.data); 35 | user.sk = privkey; 36 | updateProfile = true; 37 | } 38 | 39 | if (user.type == undefined) { 40 | user.type = UserIdentifierType.PrivateKey; 41 | updateProfile = true; 42 | } 43 | 44 | if (updateProfile) { 45 | const _user: UserIdentifier = { 46 | pubkey: user.pubkey, 47 | sk: user.sk, 48 | npub: user.npub, 49 | type: user.type, 50 | }; 51 | //@ts-ignore 52 | await messageBackground("storage/set-user", _user); 53 | } 54 | 55 | return user; 56 | } 57 | 58 | return null; 59 | } 60 | -------------------------------------------------------------------------------- /src/lib/nostr/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { nip19 } from "nostr-tools"; 2 | 3 | export function pubkeyToNpub(pubkey: string | undefined): string { 4 | if (pubkey === undefined) return undefined; 5 | try { 6 | return nip19.npubEncode(pubkey); 7 | } catch (e) {} 8 | return undefined; 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/nostr/useNotes.ts: -------------------------------------------------------------------------------- 1 | import type { NDKFilter } from "@nostr-dev-kit/ndk"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | 4 | import { STALE_TIME } from "~constants/nostr"; 5 | 6 | import { useNostr } from "./NostrProvider"; 7 | import { wineSearch } from "~lib/nostr.wine"; 8 | import { useAppStore } from "~lib/zustand/app"; 9 | 10 | export function useNotes({ 11 | key, 12 | query = false, 13 | }: { 14 | key: string; 15 | query: boolean; 16 | }) { 17 | const settings = useAppStore((state) => state.settings); 18 | 19 | const { nostr } = useNostr(); 20 | const { status, data, error, isFetching } = useQuery({ 21 | enabled: query == true && !!nostr, 22 | queryKey: ["note", key], 23 | queryFn: async () => { 24 | // get by "r" tags 25 | let filter: NDKFilter = { 26 | kinds: [1], 27 | "#r": [key], 28 | }; 29 | let events = await nostr.fetchEvents(filter); 30 | 31 | // get notes by wine 32 | if (settings.notes.wineSearch) { 33 | let _events = await wineSearch(key); 34 | events = [...events, ..._events]; 35 | } 36 | 37 | // get unique 38 | events = [...new Map(events.map((item) => [item.id, item])).values()]; 39 | 40 | return events; 41 | }, 42 | refetchOnWindowFocus: false, 43 | refetchOnMount: false, 44 | refetchOnReconnect: false, 45 | staleTime: STALE_TIME, 46 | }); 47 | 48 | return { status, data, error, isFetching }; 49 | } 50 | -------------------------------------------------------------------------------- /src/lib/nostr/useNotesMutation.ts: -------------------------------------------------------------------------------- 1 | import type { NDKEvent } from "@nostr-dev-kit/ndk" 2 | import { useMutation, useQueryClient } from "@tanstack/react-query" 3 | 4 | import { useNostr } from "./NostrProvider" 5 | 6 | export function useNotesMutation() { 7 | const queryClient = useQueryClient() 8 | const { nostr } = useNostr() 9 | 10 | return useMutation({ 11 | mutationFn: async ({ 12 | event, 13 | pageUrl 14 | }: { 15 | event: NDKEvent 16 | pageUrl: string 17 | }) => { 18 | await nostr.signAndPublishEvent(event) 19 | return { event, pageUrl } 20 | }, 21 | onSettled: async (props) => { 22 | queryClient.invalidateQueries({ 23 | queryKey: ["note", props.pageUrl] 24 | }) 25 | } 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/nostr/useNotesReactions.ts: -------------------------------------------------------------------------------- 1 | import type { NDKFilter } from "@nostr-dev-kit/ndk" 2 | import { useQuery } from "@tanstack/react-query" 3 | 4 | import { STALE_TIME } from "~constants/nostr" 5 | 6 | import { useNostr } from "./NostrProvider" 7 | 8 | export function useNotesReactions({ eventId }: { eventId: string }) { 9 | const { nostr } = useNostr() 10 | const { status, data, error, isFetching } = useQuery({ 11 | enabled: eventId !== undefined && !!nostr, 12 | queryKey: ["reactions", eventId], 13 | queryFn: async () => { 14 | let filter: NDKFilter = { 15 | kinds: [7], 16 | "#e": [eventId] 17 | } 18 | 19 | const events = await nostr.fetchEvents(filter) 20 | 21 | let reactions = {} 22 | events.forEach((e) => { 23 | const reaction = e.content 24 | if (!reactions[reaction]) { 25 | reactions[reaction] = { count: 0, users: [] } 26 | } 27 | reactions[reaction]["count"] += 1 28 | reactions[reaction]["users"].push(e.author.pubkey) 29 | }) 30 | 31 | return reactions 32 | }, 33 | refetchOnWindowFocus: false, 34 | refetchOnMount: false, 35 | refetchOnReconnect: false, 36 | staleTime: STALE_TIME 37 | }) 38 | 39 | return { status, data, error, isFetching } 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/nostr/useNotesReactionsMutation.ts: -------------------------------------------------------------------------------- 1 | import type { NDKEvent } from "@nostr-dev-kit/ndk"; 2 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 3 | 4 | export function useNotesReactionsMutation() { 5 | const queryClient = useQueryClient(); 6 | 7 | return useMutation({ 8 | mutationFn: async ({ 9 | event, 10 | reaction, 11 | }: { 12 | event: NDKEvent; 13 | reaction: string; 14 | }) => { 15 | await event.react(reaction); 16 | return { event, reaction }; 17 | }, 18 | onSettled: async (props) => { 19 | queryClient.invalidateQueries({ 20 | queryKey: ["reactions", props.event.id], 21 | }); 22 | }, 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/nostr/useNotesReplies.ts: -------------------------------------------------------------------------------- 1 | import type { NDKFilter } from "@nostr-dev-kit/ndk"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | 4 | import { STALE_TIME } from "~constants/nostr"; 5 | 6 | import { useNostr } from "./NostrProvider"; 7 | 8 | export function useNotesReplies({ 9 | key, 10 | replyToEventId, 11 | query = false, 12 | }: { 13 | key: string; 14 | replyToEventId?: string; 15 | query: boolean; 16 | }) { 17 | const { nostr } = useNostr(); 18 | const { status, data, error, isFetching } = useQuery({ 19 | enabled: query == true && !!nostr, 20 | queryKey: ["note", key, "replies", replyToEventId], 21 | queryFn: async () => { 22 | let filter: NDKFilter = { 23 | kinds: [1], 24 | ["#e"]: [replyToEventId], 25 | }; 26 | 27 | return await nostr.fetchEvents(filter); 28 | }, 29 | refetchOnWindowFocus: false, 30 | refetchOnMount: false, 31 | refetchOnReconnect: false, 32 | staleTime: STALE_TIME, 33 | }); 34 | 35 | return { status, data, error, isFetching }; 36 | } 37 | -------------------------------------------------------------------------------- /src/lib/nostr/useUserProfile.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | 3 | import { STALE_TIME } from "~constants/nostr"; 4 | 5 | import { useNostr } from "./NostrProvider"; 6 | 7 | export function useUserProfile(hexpubkey: string | undefined) { 8 | const { nostr } = useNostr(); 9 | const { status, data, error, isFetching } = useQuery({ 10 | enabled: hexpubkey !== undefined && !!nostr, 11 | queryKey: ["user", hexpubkey], 12 | queryFn: async () => { 13 | const user = nostr.ndk.getUser({ 14 | hexpubkey: hexpubkey, 15 | }); 16 | await user.fetchProfile(); 17 | return user.profile; 18 | }, 19 | refetchOnWindowFocus: false, 20 | refetchOnMount: false, 21 | refetchOnReconnect: false, 22 | staleTime: STALE_TIME, 23 | }); 24 | 25 | return { status, data, error, isFetching }; 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/parse-url/index.ts: -------------------------------------------------------------------------------- 1 | // Dependencies 2 | 3 | import normalizeUrl from "normalize-url"; 4 | import parsePath from "parse-path"; 5 | 6 | /** 7 | * parseUrl 8 | * Parses the input url. 9 | * 10 | * **Note**: This *throws* if invalid urls are provided. 11 | * 12 | * @name parseUrl 13 | * @function 14 | * @param {String} url The input url. 15 | * @param {Boolean|Object} normalize Whether to normalize the url or not. 16 | * Default is `false`. If `true`, the url will 17 | * be normalized. If an object, it will be the 18 | * options object sent to [`normalize-url`](https://github.com/sindresorhus/normalize-url). 19 | * 20 | * For SSH urls, normalize won't work. 21 | * 22 | * @return {Object} An object containing the following fields: 23 | * 24 | * - `protocols` (Array): An array with the url protocols (usually it has one element). 25 | * - `protocol` (String): The first protocol, `"ssh"` (if the url is a ssh url) or `"file"`. 26 | * - `port` (null|Number): The domain port. 27 | * - `resource` (String): The url domain (including subdomains). 28 | * - `host` (String): The fully qualified domain name of a network host, or its IP address. 29 | * - `user` (String): The authentication user (usually for ssh urls). 30 | * - `pathname` (String): The url pathname. 31 | * - `hash` (String): The url hash. 32 | * - `search` (String): The url querystring value. 33 | * - `href` (String): The input url. 34 | * - `query` (Object): The url querystring, parsed as object. 35 | * - `parse_failed` (Boolean): Whether the parsing failed or not. 36 | */ 37 | const parseUrl = (url, normalize = false) => { 38 | // Constants 39 | /** 40 | * ([a-z_][a-z0-9_-]{0,31}) Try to match the user 41 | * ([\w\.\-@]+) Match the host/resource 42 | * (([\~,\.\w,\-,\_,\/,\s]|%[0-9A-Fa-f]{2})+?(?:\.git|\/)?) Match the path, allowing spaces/white 43 | */ 44 | const GIT_RE = 45 | /^(?:([a-z_][a-z0-9_-]{0,31})@|https?:\/\/)([\w\.\-@]+)[\/:](([\~,\.\w,\-,\_,\/,\s]|%[0-9A-Fa-f]{2})+?(?:\.git|\/)?)$/; 46 | 47 | const throwErr = (msg) => { 48 | const err = new Error(msg); 49 | //@ts-ignore 50 | err.subject_url = url; 51 | throw err; 52 | }; 53 | 54 | if (typeof url !== "string" || !url.trim()) { 55 | throwErr("Invalid url."); 56 | } 57 | 58 | if (url.length > parseUrl.MAX_INPUT_LENGTH) { 59 | throwErr( 60 | "Input exceeds maximum length. If needed, change the value of parseUrl.MAX_INPUT_LENGTH." 61 | ); 62 | } 63 | 64 | if (normalize) { 65 | if (typeof normalize !== "object") { 66 | //@ts-ignore 67 | normalize = { 68 | stripHash: false, 69 | }; 70 | } 71 | //@ts-ignore 72 | url = normalizeUrl(url, normalize); 73 | } 74 | 75 | const parsed = parsePath(url); 76 | 77 | // Potential git-ssh urls 78 | if (parsed.parse_failed) { 79 | const matched = parsed.href.match(GIT_RE); 80 | 81 | if (matched) { 82 | parsed.protocols = ["ssh"]; 83 | parsed.protocol = "ssh"; 84 | parsed.resource = matched[2]; 85 | parsed.host = matched[2]; 86 | parsed.user = matched[1]; 87 | parsed.pathname = `/${matched[3]}`; 88 | parsed.parse_failed = false; 89 | } else { 90 | throwErr("URL parsing failed."); 91 | } 92 | } 93 | 94 | return parsed; 95 | }; 96 | 97 | parseUrl.MAX_INPUT_LENGTH = 2048; 98 | 99 | export default parseUrl; 100 | -------------------------------------------------------------------------------- /src/lib/utils/getDateTimeSince.ts: -------------------------------------------------------------------------------- 1 | export default function getDateTimeSince(timestamp: number) { 2 | var dateNow = new Date(); 3 | 4 | timestamp = timestamp * 1000; 5 | var datetimeSince = new Date(timestamp); 6 | 7 | var diff = dateNow.getTime() - datetimeSince.getTime(); 8 | 9 | var msec = diff; 10 | var hh = Math.floor(msec / 1000 / 60 / 60); 11 | msec -= hh * 1000 * 60 * 60; 12 | var mm = Math.floor(msec / 1000 / 60); 13 | msec -= mm * 1000 * 60; 14 | var ss = Math.floor(msec / 1000); 15 | msec -= ss * 1000; 16 | 17 | if (hh >= 24) { 18 | return Math.floor(hh / 24).toString() + "d"; 19 | } 20 | 21 | if (hh >= 1) { 22 | return hh + "h"; 23 | } 24 | 25 | if (mm >= 1) { 26 | return mm + "m"; 27 | } 28 | 29 | if (ss >= 1) { 30 | return ss + "s"; 31 | } 32 | } -------------------------------------------------------------------------------- /src/lib/w3/getShortenUrl.ts: -------------------------------------------------------------------------------- 1 | export async function getShortenUrl(url: string) { 2 | const res = await fetch(`https://w3.do/get?url=${url}`) 3 | if (!res.ok) { 4 | return undefined 5 | } 6 | 7 | const data = await res.json() 8 | return data 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/zustand/app.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { DEFAULT_SETTINGS } from "~constants/settings"; 3 | 4 | import { AppScreens } from "~types/app/AppScreens"; 5 | import { NotesView } from "~types/app/NotesView"; 6 | import { type Settings } from "~types/Settings"; 7 | 8 | interface AppState { 9 | isDrawerShowing: boolean; 10 | setDrawerShowing: (state: boolean) => void; 11 | page: AppScreens; 12 | setPage: (page: AppScreens) => void; 13 | pageTitle: string; 14 | setPageTitle: (pageTitle: string) => void; 15 | pageUrl: string; 16 | setPageUrl: (pageUrl: string) => void; 17 | domain: string; 18 | setDomain: (pageUrl: string) => void; 19 | notesView: NotesView; 20 | setNotesView: (view: NotesView) => void; 21 | settings: Settings; 22 | setSettings: (page: Settings) => void; 23 | toast: string | undefined; 24 | setToast: (toast: string | undefined) => void; 25 | } 26 | 27 | export const useAppStore = create()((set, get) => ({ 28 | isDrawerShowing: false, 29 | setDrawerShowing: (state: boolean) => set({ isDrawerShowing: state }), 30 | page: AppScreens.Feed, 31 | setPage: (page: AppScreens) => set({ page }), 32 | pageTitle: "", 33 | setPageTitle: (pageTitle: string) => set({ pageTitle }), 34 | pageUrl: "", 35 | setPageUrl: (pageUrl: string) => set({ pageUrl }), 36 | domain: "", 37 | setDomain: (domain: string) => set({ domain }), 38 | notesView: NotesView.Page, 39 | setNotesView: (view: NotesView) => set({ notesView: view }), 40 | settings: DEFAULT_SETTINGS, 41 | setSettings: (settings) => { 42 | set({ settings }); 43 | }, 44 | toast: undefined, 45 | setToast: (toast) => set({ toast }), 46 | })); 47 | -------------------------------------------------------------------------------- /src/lib/zustand/nostr.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand" 2 | 3 | import type { UserIdentifier } from "~types/nostr/UserIdentifier" 4 | 5 | interface NostrState { 6 | user: UserIdentifier | undefined 7 | setUser: (user: UserIdentifier | undefined | null) => void 8 | } 9 | 10 | export const useNostrStore = create()((set, get) => ({ 11 | user: null, 12 | setUser: (user: UserIdentifier | undefined) => set({ user: user }) 13 | })) 14 | -------------------------------------------------------------------------------- /src/lib/zustand/popup.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | import { Storage } from "@plasmohq/storage"; 4 | 5 | import { PopupScreens, WelcomeScreens } from "~types/app/PopupScreens"; 6 | import { type Settings } from "~types/Settings"; 7 | import { DEFAULT_SETTINGS } from "~constants/settings"; 8 | 9 | interface PopupState { 10 | page: PopupScreens; 11 | setPage: (page: PopupScreens) => void; 12 | settings: Settings; 13 | setSettings: (page: Settings) => void; 14 | welcomeScreen: WelcomeScreens; 15 | setWelcomeScreen: (page: WelcomeScreens) => void; 16 | } 17 | 18 | export const usePopupStore = create()((set, get) => ({ 19 | page: PopupScreens.Loading, 20 | setPage: (page: PopupScreens) => set({ page }), 21 | settings: DEFAULT_SETTINGS, 22 | setSettings: async (settings) => { 23 | set({ settings }); 24 | const storage = new Storage(); 25 | await storage.set("settings", settings); 26 | }, 27 | welcomeScreen: WelcomeScreens.Welcome, 28 | setWelcomeScreen: (welcomeScreen: WelcomeScreens) => set({ welcomeScreen }), 29 | })); 30 | -------------------------------------------------------------------------------- /src/lib/zustand/sidepanel.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { DEFAULT_SETTINGS } from "~constants/settings"; 3 | import type { Settings } from "~types/Settings"; 4 | import { SidePanelScreens } from "~types/sidepanel/SidePanelScreens"; 5 | 6 | interface SidePanelState { 7 | currentTab: { url: string; title: string; domain: string } | undefined; 8 | setCurrentTab: (tab: { url: string; title: string; domain: string }) => void; 9 | currentScreen: SidePanelScreens; 10 | setCurrentScreen: (screen: SidePanelScreens) => void; 11 | settings: Settings; 12 | setSettings: (page: Settings) => void; 13 | toast: string | undefined; 14 | setToast: (toast: string | undefined) => void; 15 | showNoteEditor: boolean; 16 | setShowNoteEditor: (show: boolean) => void; 17 | } 18 | 19 | export const useSidePanelStore = create()((set, get) => ({ 20 | currentTab: undefined, 21 | setCurrentTab: (tab) => set({ currentTab: tab }), 22 | currentScreen: SidePanelScreens.Feed, 23 | setCurrentScreen: (screen) => set({ currentScreen: screen }), 24 | settings: DEFAULT_SETTINGS, 25 | setSettings: (settings) => { 26 | set({ settings }); 27 | }, 28 | toast: undefined, 29 | setToast: (toast) => set({ toast }), 30 | showNoteEditor: true, 31 | setShowNoteEditor: (show) => set({ showNoteEditor: show }), 32 | })); 33 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from "next/app"; 2 | import "../styles.css"; 3 | 4 | export default function App({ Component, pageProps }: AppProps) { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import UiLanding from "~ui/landing"; 2 | import Head from "next/head"; 3 | 4 | export default function IndexPage() { 5 | return ( 6 | <> 7 | 8 | 9 | Satcom - Adding collaborative layer to your Internet browsing 10 | experience. 11 | 12 | 16 | 21 | 22 | 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@radix-ui/themes"; 2 | import "@radix-ui/themes/styles.css"; 3 | import { useEffect } from "react"; 4 | import "styles.css"; 5 | import { messageBackground, messageBackgroundRelay } from "~lib/chrome"; 6 | import UI from "~ui/common/UI"; 7 | import About from "~ui/popup/About"; 8 | 9 | // import UiPopup from "~ui/popup"; 10 | 11 | export default function IndexPopup() { 12 | function open() { 13 | //@ts-ignore 14 | messageBackground("sidepanel/open-panel", {}); 15 | // chrome.sidePanel.setOptions({ path: "sidepanel.html", enabled: true }); 16 | } 17 | return ( 18 |
19 | 20 | 21 | 22 | 23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/sidepanel/index.tsx: -------------------------------------------------------------------------------- 1 | import "@radix-ui/themes/styles.css"; 2 | import "styles.css"; 3 | import UiSidebar from "~ui/sidebar"; 4 | 5 | export default function IndexSidebar() { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /** 6 | custom 7 | */ 8 | 9 | .popup_container { 10 | width: 375px; 11 | height: 440px; 12 | cursor: default; 13 | background-color: #161c26; 14 | } 15 | 16 | @keyframes spin { 17 | from { 18 | transform: rotate(0deg); 19 | } 20 | to { 21 | transform: rotate(360deg); 22 | } 23 | } -------------------------------------------------------------------------------- /src/types/Settings.ts: -------------------------------------------------------------------------------- 1 | export type Settings = { 2 | appearance: { 3 | iconDisplay: boolean; 4 | iconPlacement: string; 5 | windowSize: string; 6 | iconOffset: number; 7 | windowTransparency: boolean; 8 | }; 9 | nostr: { 10 | relays: string[]; 11 | }; 12 | notes: { 13 | shorten: boolean; 14 | credit: boolean; 15 | sortBy: string; 16 | nostrIcons: boolean; 17 | wineSearch: boolean; 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /src/types/app/AppScreens.ts: -------------------------------------------------------------------------------- 1 | export enum AppScreens { 2 | Feed = "Feed", 3 | NewNote = "NewNote", 4 | } -------------------------------------------------------------------------------- /src/types/app/NotesView.ts: -------------------------------------------------------------------------------- 1 | export enum NotesView { 2 | Global = "Global", 3 | Page = "Page" 4 | } 5 | -------------------------------------------------------------------------------- /src/types/app/PopupScreens.ts: -------------------------------------------------------------------------------- 1 | export enum PopupScreens { 2 | Loading = "Loading", 3 | Welcome = "Welcome", 4 | UserCreate = "UserCreate", 5 | } 6 | 7 | export enum WelcomeScreens { 8 | Welcome = "Welcome", 9 | CreateNsec = "CreateNsec", 10 | SignInNsec = "SignInNsec", 11 | NostrLogin = "NostrLogin", 12 | } 13 | -------------------------------------------------------------------------------- /src/types/nostr/Note.ts: -------------------------------------------------------------------------------- 1 | import type { NDKEvent } from "@nostr-dev-kit/ndk"; 2 | 3 | export type Note = { 4 | event: NDKEvent; 5 | reactions: { "+": number; "-": number }; 6 | replies: number; 7 | }; 8 | -------------------------------------------------------------------------------- /src/types/nostr/UserIdentifier.ts: -------------------------------------------------------------------------------- 1 | export enum UserIdentifierType { 2 | PrivateKey, 3 | Nip46 4 | } 5 | 6 | export type UserIdentifier = { 7 | pubkey: string; 8 | nsec?: string; 9 | sk: string|Uint8Array; 10 | npub: string; 11 | type: UserIdentifierType; 12 | relays?: string[]; 13 | }; 14 | -------------------------------------------------------------------------------- /src/types/sidepanel/SidePanelScreens.ts: -------------------------------------------------------------------------------- 1 | export enum SidePanelScreens { 2 | Menu = "Menu", 3 | Feed = "Feed", 4 | GlobalFeed = "GlobalFeed", 5 | NewNote = "NewNote", 6 | SignIn = "SignIn", 7 | } 8 | -------------------------------------------------------------------------------- /src/ui/common/Link.tsx: -------------------------------------------------------------------------------- 1 | import { Link as RadixLink } from "@radix-ui/themes"; 2 | 3 | export default function Link({ 4 | children, 5 | href, 6 | target = "_blank", 7 | className, 8 | }: { 9 | children: React.ReactNode; 10 | href: string; 11 | target?: string; 12 | className?: string; 13 | }) { 14 | return ( 15 | 27 | {children} 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/ui/common/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { SymbolIcon } from "@radix-ui/react-icons" 2 | import { Flex, IconButton } from "@radix-ui/themes" 3 | 4 | export default function Loading() { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /src/ui/common/Note/NoteContent.tsx: -------------------------------------------------------------------------------- 1 | import type { NDKEvent } from "@nostr-dev-kit/ndk"; 2 | import { Flex, Text } from "@radix-ui/themes"; 3 | import { useMemo, type ReactNode } from "react"; 4 | import { stripHtml } from "string-strip-html"; 5 | import { 6 | NOSTR_MENTIONS, 7 | NOSTR_EVENTS, 8 | NOSTR_REDIRECT_URL, 9 | } from "~constants/nostr"; 10 | import { IMAGES, AUDIOS, VIDEOS } from "~constants/files"; 11 | import reactStringReplace from "react-string-replace"; 12 | import { nanoid } from "nanoid"; 13 | import NoteLink from "./NoteLink"; 14 | import { useAppStore } from "~lib/zustand/app"; 15 | import { FileIcon, PersonIcon } from "@radix-ui/react-icons"; 16 | import NoteImage from "./NoteImage"; 17 | import { nip19 } from "nostr-tools"; 18 | import UserName from "~ui/common/UserName"; 19 | import { APP_CREDIT } from "~constants/global"; 20 | 21 | export default function NoteContent({ event }: { event: NDKEvent }) { 22 | const pageUrl = useAppStore((state) => state.pageUrl); 23 | const settings = useAppStore((state) => state.settings); 24 | 25 | function getLastIndexOfChar(content: string, char: string) { 26 | return content.lastIndexOf(char); 27 | } 28 | 29 | function removeLinkLastLine(content: string) { 30 | // remove credit attached by app if present 31 | content = content.replace(`\n\n${APP_CREDIT}`, ""); 32 | 33 | const lastDoubleNewLine = getLastIndexOfChar(content, "\n\n"); 34 | 35 | // remove shorten url attached by app 36 | const lastLineIsW3 = getLastIndexOfChar(content, "https://w3.do/"); 37 | if (lastDoubleNewLine > 0 && lastLineIsW3 - lastDoubleNewLine <= 2) { 38 | return content.substring(0, lastDoubleNewLine); 39 | } 40 | 41 | // remove url of this page attached by app 42 | const lastLineIsUrl = getLastIndexOfChar(content, pageUrl); 43 | if (lastDoubleNewLine > 0 && lastLineIsUrl - lastDoubleNewLine <= 2) { 44 | return content.substring(0, lastDoubleNewLine); 45 | } 46 | 47 | // remove if last line is pageUrl 48 | if (content.endsWith(pageUrl)) { 49 | return content.substring(0, content.length - pageUrl.length); 50 | } 51 | 52 | return content; 53 | } 54 | 55 | function removePageUrl(content: string) { 56 | // todo: how to make it more fuzzy search? 57 | content = removeLinkLastLine(content); 58 | return content; 59 | } 60 | 61 | const richContent = useMemo(() => { 62 | let content = event.content; 63 | 64 | content = removePageUrl(content); 65 | 66 | let parsedContent: string | ReactNode[] = stripHtml( 67 | content.replace(/\n{2,}\s*/g, "\n") 68 | ).result; 69 | 70 | let images: string[] = []; 71 | let videos: string[] = []; 72 | let audios: string[] = []; 73 | let events: string[] = []; 74 | 75 | const text = parsedContent; 76 | 77 | // split `text` by space, breakline, comma and dot etc 78 | const words = text.split(/[\s,]+/); 79 | 80 | const geturl = new RegExp( 81 | "((ftp|http|https|gopher|mailto|news|nntp|telnet|wais|file|prospero|aim|webcal):(([A-Za-z0-9$_.+!*(),;/?:@&~=-])|%[A-Fa-f0-9]{2}){2,}(#([a-zA-Z0-9][a-zA-Z0-9$_.+!*(),;/?:@&~=%-]*))?([A-Za-z0-9$_+!*();/?:~-]))", 82 | "g" 83 | ); 84 | let urls = []; 85 | try { 86 | urls = [...text.match(geturl)]; 87 | } catch (e) {} 88 | 89 | images = urls.filter((word) => 90 | IMAGES.some((el) => { 91 | const url = new URL(word); 92 | const extension = url.pathname.split(".")[1]; 93 | if (extension === el) return true; 94 | return false; 95 | }) 96 | ); 97 | videos = urls.filter((word) => 98 | VIDEOS.some((el) => { 99 | const url = new URL(word); 100 | const extension = url.pathname.split(".")[1]; 101 | if (extension === el) return true; 102 | return false; 103 | }) 104 | ); 105 | audios = urls.filter((word) => 106 | AUDIOS.some((el) => { 107 | const url = new URL(word); 108 | const extension = url.pathname.split(".")[1]; 109 | if (extension === el) return true; 110 | return false; 111 | }) 112 | ); 113 | 114 | events = words.filter((word) => 115 | NOSTR_EVENTS.some((el) => word.startsWith(el)) 116 | ); 117 | 118 | const hashtags = words.filter((word) => word.startsWith("#")); 119 | const mentions = words.filter((word) => 120 | NOSTR_MENTIONS.some((el) => word.startsWith(el)) 121 | ); 122 | 123 | try { 124 | if (images.length) { 125 | for (const image of images) { 126 | parsedContent = reactStringReplace( 127 | parsedContent, 128 | image, 129 | (match, i) => 130 | ); 131 | } 132 | } 133 | 134 | if (videos.length) { 135 | for (const video of videos) { 136 | parsedContent = reactStringReplace( 137 | parsedContent, 138 | video, 139 | (match, i) => ( 140 |