├── images ├── icon_16.png ├── icon_32.png ├── icon_48.png ├── icon_128.png └── cloutmask.svg ├── js └── options.js ├── vendor ├── tribute │ ├── tribute.css │ ├── LICENSE.txt │ └── tribute.min.js ├── taboverride │ ├── LICENSE.txt │ └── taboverride.min.js ├── buffer │ ├── LICENSE.txt │ └── buffer.min.js ├── sweetalert2 │ └── LICENSE.txt └── bootstrap │ └── LICENSE.txt ├── options.html ├── .github └── workflows │ └── release.yml ├── LICENSE ├── README.md ├── manifest.json ├── lib ├── common.js ├── embed.js ├── follow.js ├── cloutmask.js ├── api.js ├── post.js ├── profile.js └── nft.js ├── background.js ├── main.css └── main.js /images/icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iPaulPro/BitCloutPlus/HEAD/images/icon_16.png -------------------------------------------------------------------------------- /images/icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iPaulPro/BitCloutPlus/HEAD/images/icon_32.png -------------------------------------------------------------------------------- /images/icon_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iPaulPro/BitCloutPlus/HEAD/images/icon_48.png -------------------------------------------------------------------------------- /images/icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iPaulPro/BitCloutPlus/HEAD/images/icon_128.png -------------------------------------------------------------------------------- /js/options.js: -------------------------------------------------------------------------------- 1 | const saveOptions = () => { 2 | const longPostEnabled = document.getElementById('longPostCheck').checked 3 | chrome.storage.local.set({ longPost: longPostEnabled }, () => { 4 | window.close() 5 | }) 6 | } 7 | 8 | const restoreOptions = () => { 9 | chrome.storage.local.get(['longPost'], items => { 10 | const enabled = items.longPost === undefined || items.longPost 11 | document.getElementById('longPostCheck').checked = enabled 12 | }) 13 | } 14 | 15 | document.addEventListener('DOMContentLoaded', restoreOptions); 16 | document.getElementById('save').addEventListener('click', saveOptions); -------------------------------------------------------------------------------- /vendor/tribute/tribute.css: -------------------------------------------------------------------------------- 1 | .tribute-container { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | height: auto; 6 | max-height: 300px; 7 | max-width: 500px; 8 | overflow: auto; 9 | display: block; 10 | z-index: 999999; 11 | } 12 | .tribute-container ul { 13 | margin: 0; 14 | margin-top: 2px; 15 | padding: 0; 16 | list-style: none; 17 | background: #efefef; 18 | } 19 | .tribute-container li { 20 | padding: 5px 5px; 21 | cursor: pointer; 22 | } 23 | .tribute-container li.highlight { 24 | background: #ddd; 25 | } 26 | .tribute-container li span { 27 | font-weight: bold; 28 | } 29 | .tribute-container li.no-match { 30 | cursor: default; 31 | } 32 | .tribute-container .menu-highlighted { 33 | font-weight: bold; 34 | } -------------------------------------------------------------------------------- /options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BitClout+ Options 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |

Settings

14 |
15 |
16 |
17 | 18 | 19 |
20 |
21 |
22 | 23 |
24 |
25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Web Store Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: Create Zip 17 | run: zip -r release.zip . -x ".git/*" ".github/*" ".gitignore" "README.md" 18 | 19 | - name: Create GitHub Release 20 | uses: ncipollo/release-action@v1 21 | with: 22 | artifacts: "release.zip" 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | - name: Publish to Web Store 26 | uses: trmcnvn/chrome-addon@v2 27 | with: 28 | extension: ${{ secrets.GOOGLE_EXTENSION_ID }} 29 | zip: release.zip 30 | client-id: ${{ secrets.GOOGLE_CLIENT_ID }} 31 | client-secret: ${{ secrets.GOOGLE_CLIENT_SECRET }} 32 | refresh-token: ${{ secrets.GOOGLE_REFRESH_TOKEN }} 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Paul Burke 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /vendor/taboverride/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Bill Bryant 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /vendor/buffer/LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Feross Aboukhadijeh, and other contributors. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /vendor/sweetalert2/LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Tristan Edwards & Limon Monte 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BitClout Plus 2 | 3 | [![Web Store Release](https://github.com/iPaulPro/BitCloutPlus/actions/workflows/release.yml/badge.svg)](https://github.com/iPaulPro/BitCloutPlus/actions/workflows/release.yml) 4 | 5 | Chrome / Brave extension to enhance bitclout.com and node.deso.org pages 6 | 7 | 8 | 9 | ## Introduction 10 | 11 | Details can be found at [bitclout.plus](https://bitclout.plus) 12 | 13 | ## Installation 14 | 15 | Install from the [Chrome Web Store](https://get.bitclout.plus), or you may install manually: 16 | 17 | #### Step 1 18 | 19 | Download the latest release from the [releases](https://github.com/iPaulPro/BitCloutPlus/releases) page and unzip. 20 | 21 | #### Step 2 22 | 23 | Enable [developer mode](https://developer.chrome.com/docs/extensions/mv2/faq/#faq-dev-01) in Chrome or Brave. 24 | 25 | #### Step 3 26 | 27 | From the [Extensions](chrome://extensions/) page click "Load unpacked". Select the unzipped project folder. 28 | 29 | 30 | ## Credits 31 | 32 | Created by [paulburke](https://bitclout.com/u/paulburke) 33 | -------------------------------------------------------------------------------- /vendor/bootstrap/LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2011-2021 Twitter, Inc. 4 | Copyright (c) 2011-2021 The Bootstrap Authors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /vendor/tribute/LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2020 ZURB, Inc. 4 | Copyright (c) 2014 Jeff Collins 5 | Copyright (c) 2012 Matt York 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in 15 | all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BitClout Plus", 3 | "description": "BitClout on steroids", 4 | "version": "0.12.4", 5 | "manifest_version": 3, 6 | "content_security_policy": { 7 | "extension_pages": "script-src 'self'; connect-src https://node.deso.org https://bitclout.com; object-src 'self'; frame-ancestors 'none';" 8 | }, 9 | "author": "Paul Burke", 10 | "homepage_url": "https://github.com/iPaulPro/BitCloutPlus", 11 | "short_name": "BitClout+", 12 | "icons": { 13 | "16": "images/icon_16.png", 14 | "32": "images/icon_32.png", 15 | "48": "images/icon_48.png", 16 | "128": "images/icon_128.png" 17 | }, 18 | "omnibox": { 19 | "keyword": "deso" 20 | }, 21 | "content_scripts": [ 22 | { 23 | "matches": [ 24 | "https://bitclout.com/*", 25 | "https://node.deso.org/*" 26 | ], 27 | "js": [ 28 | "vendor/tribute/tribute.min.js", 29 | "vendor/buffer/buffer.min.js", 30 | "vendor/sweetalert2/sweetalert2.all.min.js", 31 | "vendor/taboverride/taboverride.min.js", 32 | "lib/common.js", 33 | "lib/embed.js", 34 | "lib/api.js", 35 | "lib/nft.js", 36 | "lib/post.js", 37 | "lib/profile.js", 38 | "lib/follow.js", 39 | "lib/cloutmask.js", 40 | "main.js" 41 | ], 42 | "css": [ 43 | "vendor/tribute/tribute.css", 44 | "main.css" 45 | ] 46 | } 47 | ], 48 | "web_accessible_resources": [ 49 | { 50 | "resources": [ 51 | "images/*" 52 | ], 53 | "matches": [ 54 | "https://bitclout.com/*", 55 | "https://node.deso.org/*" 56 | ] 57 | } 58 | ], 59 | "background": { 60 | "service_worker": "background.js" 61 | }, 62 | "permissions": [ 63 | "storage" 64 | ], 65 | "options_ui": { 66 | "page": "options.html", 67 | "open_in_tab": false 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /images/cloutmask.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /lib/common.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) Paul Burke 2021 3 | Github: @ipaulpro/bitcloutplus 4 | Distributed under the MIT License (license terms are at http://opensource.org/licenses/MIT). 5 | */ 6 | 7 | let pendingIdentityMessageId, pendingTransactionHex 8 | 9 | const getLoggedInPublicKey = function () { 10 | const key = window.localStorage.getItem('lastLoggedInUserV2') 11 | if (!key) return undefined 12 | 13 | return JSON.parse(key) 14 | } 15 | 16 | const getLoggedInUsername = function () { 17 | const elementList = document.getElementsByClassName('change-account-selector__acount-name') 18 | 19 | try { 20 | const changeAccountSelector = elementList.item(0) 21 | return changeAccountSelector.innerText.trim() 22 | } catch (e) {} 23 | 24 | return '' 25 | } 26 | 27 | const getUsernameFromUrl = function () { 28 | const segments = new URL(window.location).pathname.split('/') 29 | if (segments[1] === 'u') return segments[2] 30 | return undefined 31 | } 32 | 33 | const getPublicKeyFromPage = () => { 34 | const topCard = document.querySelector('creator-profile-top-card') 35 | if (!topCard) return 36 | 37 | return topCard.querySelector('.creator-profile__ellipsis-restriction').innerText.trim() 38 | } 39 | 40 | const getPostHashHexFromUrl = function () { 41 | const segments = new URL(window.location).pathname.split('/') 42 | if (segments[1] === 'posts' || segments[1] === 'nft') return segments[2] 43 | return undefined 44 | } 45 | 46 | const getSpotPrice = function () { 47 | const balanceBox = document.getElementsByClassName('right-bar-creators__balance-box').item(0) 48 | 49 | try { 50 | const priceContainerDiv = balanceBox.firstElementChild 51 | const priceDiv = priceContainerDiv.children.item(1).firstElementChild 52 | return parseFloat(priceDiv.innerText.replace(/[^0-9.]+/g, '')) 53 | } catch (e) {} 54 | 55 | return 0 56 | } 57 | 58 | const postIdentityMessage = (id, method, payload) => { 59 | const identityFrame = document.getElementById('identity') 60 | if (!identityFrame) throw 'No identity frame found' 61 | 62 | identityFrame.contentWindow.postMessage({ 63 | id: id, 64 | service: 'identity', 65 | method: method, 66 | payload: payload 67 | }, '*') 68 | } 69 | 70 | const sendSignTransactionMsg = (identity, transactionHex, id) => { 71 | const payload = { 72 | transactionHex: transactionHex 73 | } 74 | 75 | if (identity) { 76 | payload.accessLevel = identity.accessLevel 77 | payload.accessLevelHmac = identity.accessLevelHmac 78 | payload.encryptedSeedHex = identity.encryptedSeedHex 79 | } 80 | 81 | pendingIdentityMessageId = id 82 | pendingTransactionHex = transactionHex 83 | 84 | postIdentityMessage(id, 'sign', payload) 85 | } 86 | 87 | const getIdentityUsers = () => { 88 | const users = window.localStorage.getItem('identityUsersV2') 89 | return users && JSON.parse(users) 90 | } 91 | 92 | const getCurrentIdentity = () => { 93 | const key = getLoggedInPublicKey() 94 | const identityUsers = getIdentityUsers() 95 | return key && identityUsers && identityUsers[key] 96 | } 97 | 98 | const uuid = () => 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { 99 | const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8) 100 | return v.toString(16) 101 | }) 102 | 103 | const isEmpty = obj => Object.keys(obj).length === 0 -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) Paul Burke 2021 3 | Github: @ipaulpro/bitcloutplus 4 | Distributed under the MIT License (license terms are at http://opensource.org/licenses/MIT). 5 | */ 6 | 7 | const PUBLIC_KEY_PREFIX = 'BC1YL' 8 | const PUBLIC_KEY_LENGTH = 55 9 | 10 | const DELAY_MS_MEMPOOL_CALL = 5 * 60 * 1000 // 5 min 11 | const KEY_LATEST_MEMPOOL_CALL = 'latest-mempool-call' 12 | const KEY_LATEST_MEMPOOL_USERS = 'latest-mempool-users' 13 | 14 | const MSG_GET_MEMPOOL_TRANSACTORS = 'get-mempool-transactors' 15 | 16 | // Omnibox 17 | 18 | chrome.omnibox.onInputEntered.addListener(async text => { 19 | // Encode user input for special characters , / ? : @ & = + $ # 20 | const query = encodeURIComponent(text) 21 | 22 | if (isPublicKeyBase58Check(query)) { 23 | let username 24 | 25 | try { 26 | username = await getUsernameForPublicKey(query) 27 | } catch (e) { 28 | console.error(`No username found for '${query}'`, e) 29 | } 30 | 31 | if (username) { 32 | await openUserInTab(username) 33 | return 34 | } 35 | } 36 | 37 | await openUserInTab(query) 38 | }) 39 | 40 | const isPublicKeyBase58Check = (query) => { 41 | return query.startsWith(PUBLIC_KEY_PREFIX) && query.length === PUBLIC_KEY_LENGTH 42 | } 43 | 44 | const getUsernameForPublicKey = (publicKey) => { 45 | if (!publicKey) return Promise.reject('Missing required parameter publicKey') 46 | 47 | return fetch(`https://node.deso.org/api/v0/get-user-name-for-public-key/${publicKey}`) 48 | .then(res => res.json()) 49 | .then(atob) 50 | } 51 | 52 | const openUserInTab = async (username) => { 53 | const newURL = 'https://node.deso.org/u/' + username 54 | await chrome.tabs.create({url: newURL}) 55 | } 56 | 57 | // Worker thread 58 | 59 | chrome.runtime.onMessage.addListener( (message, sender, sendResponse) => { 60 | switch (message.type) { 61 | case MSG_GET_MEMPOOL_TRANSACTORS: 62 | getMempoolTransactors() 63 | .then(mempoolTransactors => sendResponse({mempoolTransactors})) 64 | .catch(() => sendResponse({mempoolTransactors: []})) 65 | } 66 | 67 | return true 68 | }) 69 | 70 | const getMempoolTransactors = async () => { 71 | const savedTransactors = await getSavedMempoolTransactors() 72 | if (savedTransactors) return savedTransactors 73 | 74 | const transactions = await getMempoolTransactions() 75 | const transactors = extractTransactors(transactions) 76 | 77 | await saveTransactors(transactors) 78 | 79 | return transactors 80 | } 81 | 82 | const getSavedMempoolTransactors = async () => { 83 | const savedItems = await chrome.storage.local.get([KEY_LATEST_MEMPOOL_CALL, KEY_LATEST_MEMPOOL_USERS]) 84 | 85 | const latestCall = savedItems[KEY_LATEST_MEMPOOL_CALL] ?? 0 86 | if (Date.now() - latestCall < DELAY_MS_MEMPOOL_CALL) { 87 | const savedUsers = savedItems[KEY_LATEST_MEMPOOL_USERS] 88 | return JSON.parse(savedUsers) 89 | } 90 | 91 | return null 92 | } 93 | 94 | const getMempoolTransactions = () => { 95 | const request = { 96 | 'headers': { 97 | 'content-type': 'application/json', 98 | }, 99 | 'method': 'POST', 100 | 'body': JSON.stringify({ 101 | IsMempool: true 102 | }) 103 | } 104 | 105 | return fetch(`https://node.deso.org/api/v1/transaction-info`, request) 106 | .then(res => res.json()) 107 | .then(res => res['Transactions']) 108 | } 109 | 110 | const extractTransactors = (transactions) => { 111 | const transactors = new Set() 112 | transactions.forEach(transaction => { 113 | const transactor = transaction['TransactionMetadata']['TransactorPublicKeyBase58Check'] 114 | transactors.add(transactor) 115 | }) 116 | return [...transactors] 117 | } 118 | 119 | const saveTransactors = async (transactors) => { 120 | const items = {} 121 | items[KEY_LATEST_MEMPOOL_CALL] = Date.now() 122 | items[KEY_LATEST_MEMPOOL_USERS] = JSON.stringify(transactors) 123 | 124 | await chrome.storage.local.set(items) 125 | } 126 | -------------------------------------------------------------------------------- /vendor/taboverride/taboverride.min.js: -------------------------------------------------------------------------------- 1 | /*! taboverride v4.0.3 | https://github.com/wjbryant/taboverride 2 | (c) 2015 Bill Bryant | http://opensource.org/licenses/mit */ 3 | !function(a){"use strict";var b;"object"==typeof exports?a(exports):"function"==typeof define&&define.amd?define(["exports"],a):(b=window.tabOverride={},a(b))}(function(a){"use strict";function b(a,b){var c,d,e,f=["alt","ctrl","meta","shift"],g=a.length,h=!0;for(c=0;g>c;c+=1)if(!b[a[c]]){h=!1;break}if(h)for(c=0;cd;d+=1)if(e===a[d]){h=!0;break}}else h=!1;if(!h)break}return h}function c(a,c){return a===q&&b(s,c)}function d(a,c){return a===r&&b(t,c)}function e(a,b){return function(c,d){var e,f="";if(arguments.length){if("number"==typeof c&&(a(c),b.length=0,d&&d.length))for(e=0;e1?(i=f.slice(0,l).split(m).length-1,j=t.split(m).length-1):i=j=0}if(E===q||E===r)if(b=p,e=b.length,y=0,z=0,A=0,l!==s&&-1!==t.indexOf("\n"))if(w=0===l||"\n"===f.charAt(l-1)?l:f.lastIndexOf("\n",l-1)+1,s===f.length||"\n"===f.charAt(s)?x=s:"\n"===f.charAt(s-1)?x=s-1:(x=f.indexOf("\n",s),-1===x&&(x=f.length)),c(E,a))y=1,D.value=f.slice(0,w)+b+f.slice(w,x).replace(/\n/g,function(){return y+=1,"\n"+b})+f.slice(x),g?(g.collapse(),g.moveEnd(F,s+y*e-j-i),g.moveStart(F,l+e-i),g.select()):(D.selectionStart=l+e,D.selectionEnd=s+y*e,D.scrollTop=k);else{if(!d(E,a))return;0===f.slice(w).indexOf(b)&&(w===l?t=t.slice(e):A=e,z=e),D.value=f.slice(0,w)+f.slice(w+A,l)+t.replace(new RegExp("\n"+b,"g"),function(){return y+=1,"\n"})+f.slice(s),g?(g.collapse(),g.moveEnd(F,s-z-y*e-j-i),g.moveStart(F,l-A-i),g.select()):(D.selectionStart=l-A,D.selectionEnd=s-z-y*e)}else if(c(E,a))g?(g.text=b,g.select()):(D.value=f.slice(0,l)+b+f.slice(s),D.selectionEnd=D.selectionStart=l+e,D.scrollTop=k);else{if(!d(E,a))return;0===f.slice(l-e).indexOf(b)&&(D.value=f.slice(0,l-e)+f.slice(l),g?(g.move(F,l-e-i),g.select()):(D.selectionEnd=D.selectionStart=l-e,D.scrollTop=k))}else if(u){if(0===l||"\n"===f.charAt(l-1))return void(v=!0);if(w=f.lastIndexOf("\n",l-1)+1,x=f.indexOf("\n",l),-1===x&&(x=f.length),B=f.slice(w,x).match(/^[ \t]*/)[0],C=B.length,w+C>l)return void(v=!0);g?(g.text="\n"+B,g.select()):(D.value=f.slice(0,l)+"\n"+B+f.slice(s),D.selectionEnd=D.selectionStart=l+n+C,D.scrollTop=k)}return a.preventDefault?void a.preventDefault():(a.returnValue=!1,!1)}}function g(a){a=a||event;var b=a.keyCode;if(c(b,a)||d(b,a)||13===b&&u&&!v){if(!a.preventDefault)return a.returnValue=!1,!1;a.preventDefault()}}function h(a,b){var c,d=x[a]||[],e=d.length;for(c=0;e>c;c+=1)d[c].apply(null,b)}function i(a){function b(b){for(c=0;f>c;c+=1)b(a[c].type,a[c].handler)}var c,d,e,f=a.length;return o.addEventListener?(d=function(a){b(function(b,c){a.removeEventListener(b,c,!1)})},e=function(a){d(a),b(function(b,c){a.addEventListener(b,c,!1)})}):o.attachEvent&&(d=function(a){b(function(b,c){a.detachEvent("on"+b,c)})},e=function(a){d(a),b(function(b,c){a.attachEvent("on"+b,c)})}),{add:e,remove:d}}function j(a){h("addListeners",[a]),l.add(a)}function k(a){h("removeListeners",[a]),l.remove(a)}var l,m,n,o=window.document,p="\t",q=9,r=9,s=[],t=["shiftKey"],u=!0,v=!1,w=o.createElement("textarea"),x={};l=i([{type:"keydown",handler:f},{type:"keypress",handler:g}]),w.value="\n",m=w.value,n=m.length,w=null,a.utils={executeExtensions:h,isValidModifierKeyCombo:b,createListeners:i,addListeners:j,removeListeners:k},a.handlers={keydown:f,keypress:g},a.addExtension=function(a,b){return a&&"string"==typeof a&&"function"==typeof b&&(x[a]||(x[a]=[]),x[a].push(b)),this},a.set=function(a,b){var c,d,e,f,g,i,l;if(a)for(c=arguments.length<2||b,d=a,e=d.length,"number"!=typeof e&&(d=[d],e=1),c?(f=j,g="true"):(f=k,g=""),i=0;e>i;i+=1)l=d[i],l&&l.nodeName&&"textarea"===l.nodeName.toLowerCase()&&(h("set",[l,c]),l.setAttribute("data-taboverride-enabled",g),f(l));return this},a.tabSize=function(a){var b;if(arguments.length){if(a&&"number"==typeof a&&a>0)for(p="",b=0;a>b;b+=1)p+=" ";else p="\t";return this}return"\t"===p?0:p.length},a.autoIndent=function(a){return arguments.length?(u=a?!0:!1,this):u},a.tabKey=e(function(a){return arguments.length?void(q=a):q},s),a.untabKey=e(function(a){return arguments.length?void(r=a):r},t)}); 4 | //# sourceMappingURL=taboverride.min.js.map -------------------------------------------------------------------------------- /main.css: -------------------------------------------------------------------------------- 1 | /* Bootstrap overrides */ 2 | 3 | bs-dropdown-container .dropdown-menu { 4 | min-width: 12rem !important; 5 | } 6 | 7 | /* Hide scrollbar for Chrome, Safari and Opera */ 8 | .global__nav__inner::-webkit-scrollbar, .global__sidebar__inner::-webkit-scrollbar { 9 | display: none; 10 | } 11 | 12 | /* Hide scrollbar for IE, Edge and Firefox */ 13 | .global__nav__inner, .global__sidebar__inner { 14 | -ms-overflow-style: none; /* IE and Edge */ 15 | scrollbar-width: none; /* Firefox */ 16 | } 17 | /* Vendor overrides */ 18 | 19 | .tribute-container { 20 | filter: drop-shadow(0 0 0.25rem rgba(0, 0, 0, 0.54)); 21 | margin-top: 0.75rem; 22 | } 23 | 24 | .tribute-container ul { 25 | padding: 0 !important; 26 | background: var(--secondary) !important; 27 | border-radius: 14px !important; 28 | min-width: 220px !important; 29 | } 30 | 31 | .tribute-container li { 32 | padding: 12px 36px 12px 12px; 33 | font-size: 15px; 34 | color: var(--text); 35 | } 36 | 37 | .tribute-container li:not(:last-child) { 38 | border-bottom: 1px solid var(--border) !important; 39 | } 40 | 41 | .tribute-container li.highlight { 42 | background: var(--border) !important; 43 | } 44 | 45 | .tribute-container li:first-child.highlight { 46 | border-top-left-radius: 14px; 47 | border-top-right-radius: 14px; 48 | } 49 | 50 | .tribute-container li:last-child.highlight { 51 | border-bottom-left-radius: 14px; 52 | border-bottom-right-radius: 14px; 53 | } 54 | 55 | .tribute-avatar { 56 | height: 40px; 57 | width: 40px; 58 | border-radius: 6px; 59 | background-size: cover; 60 | background-repeat: no-repeat; 61 | background-position: 50%; 62 | } 63 | 64 | /* Project */ 65 | 66 | #plus-add-new-post { 67 | background-color: var(--secondary); 68 | border-color: var(--secondary); 69 | color: var(--grey); 70 | border-radius: 24px; 71 | padding-left: 2rem; 72 | padding-right: 2rem; 73 | } 74 | 75 | #plus-add-new-post:hover { 76 | background-color: var(--border); 77 | border-color: var(--border); 78 | color: var(--text); 79 | } 80 | 81 | #plus-profile-sell-btn { 82 | background-color: var(--border); 83 | border-color: var(--border); 84 | color: var(--textalt); 85 | } 86 | 87 | #plus-profile-sell-btn:hover { 88 | background-color: var(--secondary); 89 | border-color: var(--secondary); 90 | color: var(--text); 91 | } 92 | 93 | .plus-text-muted { 94 | color: var(--grey); 95 | } 96 | 97 | .plus-text-primary { 98 | color: var(--text); 99 | } 100 | 101 | .plus-text-red { 102 | color: var(--cred); 103 | } 104 | 105 | .plus-badge { 106 | background-color: var(--secalt) !important; 107 | color: var(--norm) !important; 108 | padding: 0.3em 0.6em !important; 109 | font-weight: normal !important; 110 | font-size: 11px !important; 111 | } 112 | 113 | .plus-badge-icon { 114 | padding: 0.5em !important; 115 | } 116 | 117 | .plus-tooltip { 118 | position: relative; 119 | display: inline-block; 120 | } 121 | 122 | .plus-tooltip .plus-tooltip-text { 123 | font-family: "Roboto", sans-serif; 124 | visibility: hidden; 125 | background-color: black; 126 | color: #fff; 127 | text-align: center; 128 | padding: 0.5em 1em; 129 | border-radius: 6px; 130 | position: absolute; 131 | z-index: 1; 132 | } 133 | 134 | .plus-tooltip:hover .plus-tooltip-text { 135 | visibility: visible; 136 | } 137 | 138 | .plus-active-indicator-container { 139 | position: absolute; 140 | right: 0; 141 | bottom: 0; 142 | background-color: white; 143 | border-radius: 50% 0 0 0; 144 | height: 16px; 145 | width: 16px; 146 | display: grid; 147 | padding: 1px 1px; 148 | place-items: center; 149 | box-shadow: -1px -1px 0.15rem #40000000; 150 | } 151 | 152 | .plus-active-indicator { 153 | width: 10px; 154 | height: 10px; 155 | clip-path: circle(50%); 156 | } 157 | 158 | .__clout-mask-disabled { 159 | opacity: 0.5; 160 | pointer-events: none; 161 | } 162 | 163 | .change-account-selector_list__inner > .change-account-selector_list-item > .plus-account-logout > .btn > .plus-account-logout-icon { 164 | visibility: hidden; 165 | } 166 | 167 | .change-account-selector_list__inner:hover > .change-account-selector_list-item > .plus-account-logout > .btn > .plus-account-logout-icon { 168 | visibility: visible; 169 | } 170 | 171 | /* Hide scrollbar for Chrome, Safari and Opera */ 172 | #plus-online-users-list::-webkit-scrollbar { 173 | display: none; 174 | } 175 | 176 | /* Hide scrollbar for IE, Edge and Firefox */ 177 | #plus-online-users-list { 178 | -ms-overflow-style: none; /* IE and Edge */ 179 | scrollbar-width: none; /* Firefox */ 180 | } 181 | 182 | .plus-message-link { 183 | color: #6c757d; 184 | } 185 | 186 | .plus-message-link:hover { 187 | color: white; 188 | } -------------------------------------------------------------------------------- /lib/embed.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) Paul Burke 2021 3 | Github: @ipaulpro/bitcloutplus 4 | Distributed under the MIT License (license terms are at http://opensource.org/licenses/MIT). 5 | 6 | Adapted from https://github.com/bitclout/frontend/ 7 | */ 8 | 9 | const isVimeoFromURL = (url) => { 10 | const pattern = /\bvimeo\.com$/ 11 | return pattern.test(url.hostname) 12 | } 13 | 14 | const isYoutubeFromURL = (url) => { 15 | const patterns = [/\byoutube\.com$/, /\byoutu\.be$/] 16 | return patterns.some((p) => p.test(url.hostname)) 17 | } 18 | 19 | const isTiktokFromURL = (url) => { 20 | const pattern = /\btiktok\.com$/ 21 | return pattern.test(url.hostname) 22 | } 23 | 24 | const isGiphyFromURL = (url) => { 25 | const pattern = /\bgiphy\.com$/ 26 | return pattern.test(url.hostname) 27 | } 28 | 29 | const isSpotifyFromURL = (url) => { 30 | const pattern = /\bspotify\.com$/ 31 | return pattern.test(url.hostname) 32 | } 33 | 34 | const isSoundCloudFromURL = (url) => { 35 | const pattern = /\bsoundcloud\.com$/ 36 | return pattern.test(url.hostname) 37 | } 38 | 39 | // This regex helps extract the correct videoID from the various forms of URLs that identify a youtube video. 40 | const youtubeParser = (url) => { 41 | const regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([A-Za-z0-9_-]{11}).*/ 42 | const match = url.match(regExp) 43 | return match && match[7].length === 11 ? match[7] : false 44 | } 45 | 46 | const constructYoutubeEmbedURL = (url) => { 47 | const youtubeVideoID = youtubeParser(url.toString()) 48 | // If we can't find the videoID, return the empty string which stops the iframe from loading. 49 | return youtubeVideoID ? `https://www.youtube.com/embed/${youtubeVideoID}` : "" 50 | } 51 | 52 | // Vimeo video URLs are simple -- anything after the last "/" in the url indicates the videoID. 53 | const vimeoParser = (url) => { 54 | const regExp = /^.*((player\.)?vimeo\.com\/)(video\/)?(\d{0,15}).*/ 55 | const match = url.match(regExp) 56 | return match && match[4] ? match[4] : false 57 | } 58 | 59 | const constructVimeoEmbedURL = (url) => { 60 | const vimeoVideoID = vimeoParser(url.toString()) 61 | return vimeoVideoID ? `https://player.vimeo.com/video/${vimeoVideoID}` : "" 62 | } 63 | 64 | const giphyParser = (url) => { 65 | const regExp = /^.*((media\.)?giphy\.com\/(gifs|media|embed|clips)\/)([A-Za-z0-9]+-)*([A-Za-z0-9]{0,20}).*/ 66 | const match = url.match(regExp) 67 | return match && match[5] ? match[5] : false 68 | } 69 | 70 | const constructGiphyEmbedURL = (url) => { 71 | const giphyId = giphyParser(url.toString()) 72 | return giphyId ? `https://giphy.com/embed/${giphyId}` : "" 73 | } 74 | 75 | const spotifyParser = (url) => { 76 | const regExp = /^.*(open\.)?spotify\.com\/(((embed\/)?(track|artist|playlist|album))|((embed-podcast\/)?(episode|show)))\/([A-Za-z0-9]{0,25}).*/ 77 | const match = url.match(regExp) 78 | if (match && match[9]) { 79 | if (match[8]) { 80 | return `embed-podcast/${match[8]}/${match[9]}` 81 | } 82 | if (match[5]) { 83 | return `embed/${match[5]}/${match[9]}` 84 | } 85 | } 86 | return false 87 | } 88 | 89 | const constructSpotifyEmbedURL = (url) => { 90 | const spotifyEmbedSuffix = spotifyParser(url.toString()) 91 | return spotifyEmbedSuffix ? `https://open.spotify.com/${spotifyEmbedSuffix}` : "" 92 | } 93 | 94 | const soundCloudParser = (url) => { 95 | const regExp = /^.*(soundcloud.com\/([a-z0-9-_]+)\/(sets\/)?([a-z0-9-_]+)).*/ 96 | const match = url.match(regExp) 97 | return match && match[1] ? match[1] : false 98 | } 99 | 100 | const constructSoundCloudEmbedURL = (url) => { 101 | const soundCloudURL = soundCloudParser(url.toString()) 102 | return soundCloudURL 103 | ? `https://w.soundcloud.com/player/?url=https://${soundCloudURL}?hide_related=true&show_comments=false` 104 | : "" 105 | } 106 | 107 | const extractTikTokVideoID = (fullTikTokURL) => { 108 | const regExp = /^.*((tiktok\.com\/)(v\/)|(@[A-Za-z0-9_-]{2,24}\/video\/)|(embed\/v2\/))(\d{0,30}).*/ 109 | const match = fullTikTokURL.match(regExp) 110 | return match && match[6] ? match[6] : false 111 | } 112 | 113 | const tiktokParser = (url) => { 114 | let tiktokURL 115 | try { 116 | tiktokURL = new URL(url) 117 | } catch (e) { 118 | return undefined 119 | } 120 | 121 | if (tiktokURL.hostname === "vm.tiktok.com") { 122 | const regExp = /^.*(vm\.tiktok\.com\/)([A-Za-z0-9]{6,12}).*/ 123 | const match = url.match(regExp) 124 | if (!match || !match[2]) { 125 | return undefined 126 | } 127 | 128 | return extractTikTokVideoID(url) 129 | } 130 | } 131 | 132 | const constructTikTokEmbedURL = (url) => { 133 | const tikTokId = tiktokParser(url.toString()) 134 | if (!tikTokId) return undefined 135 | 136 | return `https://www.tiktok.com/embed/v2/${tikTokId}` 137 | } 138 | 139 | const twitchParser = (url) => { 140 | const regExp = /^.*((player\.|clips\.)?twitch\.tv)\/(videos\/(\d{8,12})|\?video=(\d{8,12})|\?channel=([A-Za-z0-9_]{1,30})|collections\/([A-Za-z0-9]{10,20})|\?collection=([A-Za-z0-9]{10,20}(&video=\d{8,12})?)|embed\?clip=([A-Za-z0-9_-]{1,80})|([A-Za-z0-9_]{1,30}(\/clip\/([A-Za-z0-9_-]{1,80}))?)).*/ 141 | const match = url.match(regExp) 142 | if (match && match[3]) { 143 | // https://www.twitch.tv/videos/1234567890 144 | if (match[3].startsWith('videos') && match[4]) { 145 | return `player.twitch.tv/?video=${match[4]}` 146 | } 147 | // https://player.twitch.tv/?video=1234567890&parent=www.example.com 148 | if (match[3].startsWith('?video=') && match[5]) { 149 | return `player.twitch.tv/?video=${match[5]}` 150 | } 151 | // https://player.twitch.tv/?channel=xxxyyy123&parent=www.example.com 152 | if (match[3].startsWith('?channel=') && match[6]) { 153 | return `player.twitch.tv/?channel=${match[6]}` 154 | } 155 | // https://www.twitch.tv/xxxyyy123 156 | if (match[3] && match[11] && match[3] === match[11] && !match[12] && !match[13]) { 157 | return `player.twitch.tv/?channel=${match[11]}` 158 | } 159 | // https://www.twitch.tv/xxyy_1234m/clip/AbCD123JMn-rrMMSj1239G7 160 | if (match[12] && match[13]) { 161 | return `clips.twitch.tv/embed?clip=${match[13]}` 162 | } 163 | // https://clips.twitch.tv/embed?clip=AbCD123JMn-rrMMSj1239G7&parent=www.example.com 164 | if (match[10]) { 165 | return `clips.twitch.tv/embed?clip=${match[10]}` 166 | } 167 | // https://www.twitch.tv/collections/11jaabbcc2yM989x?filter=collections 168 | if (match[7]) { 169 | return `player.twitch.tv/?collection=${match[7]}` 170 | } 171 | // https://player.twitch.tv/?collection=11jaabbcc2yM989x&video=1234567890&parent=www.example.com 172 | if (match[8]) { 173 | return `player.twitch.tv/?collection=${match[8]}` 174 | } 175 | } 176 | return false 177 | } 178 | 179 | const constructTwitchEmbedURL = (url) => { 180 | const twitchParsed = twitchParser(url.toString()) 181 | return twitchParsed ? `https://${twitchParsed}` : '' 182 | } 183 | 184 | const isTwitchFromURL = (url) => { 185 | const pattern = /\btwitch\.tv$/ 186 | return pattern.test(url.hostname) 187 | } 188 | 189 | const getEmbedURL = (embedURL) => { 190 | if (!embedURL) { 191 | return undefined 192 | } 193 | let url 194 | try { 195 | url = new URL(embedURL) 196 | } catch (e) { 197 | // If the embed video URL doesn't start with http(s), try the url with that as a prefix. 198 | if (!embedURL.startsWith("https://") && !embedURL.startsWith("http://")) { 199 | return getEmbedURL(`https://${embedURL}`) 200 | } 201 | return undefined 202 | } 203 | if (isYoutubeFromURL(url)) { 204 | return constructYoutubeEmbedURL(url) 205 | } 206 | if (isVimeoFromURL(url)) { 207 | return constructVimeoEmbedURL(url) 208 | } 209 | if (isTiktokFromURL(url)) { 210 | return constructTikTokEmbedURL(url) 211 | } 212 | if (isGiphyFromURL(url)) { 213 | return constructGiphyEmbedURL(url) 214 | } 215 | if (isSpotifyFromURL(url)) { 216 | return constructSpotifyEmbedURL(url) 217 | } 218 | if (isSoundCloudFromURL(url)) { 219 | return constructSoundCloudEmbedURL(url) 220 | } 221 | if (isTwitchFromURL(url)) { 222 | const embedURL = constructTwitchEmbedURL(url) 223 | return embedURL ? embedURL + `&autoplay=false&parent=${location.hostname}` : "" 224 | } 225 | return undefined 226 | } 227 | -------------------------------------------------------------------------------- /lib/follow.js: -------------------------------------------------------------------------------- 1 | let blockJwtMsgId 2 | let pendingBlockUser = {} 3 | 4 | const isBlockedUsersUrl = () => { 5 | const segments = new URL(document.location).pathname.split('/') 6 | const params = (new URL(document.location)).searchParams 7 | return segments[1] === 'u' && segments[segments.length - 1] === 'following' && params.get('tab') === 'blocked' 8 | } 9 | 10 | const addFollowsYouBadgeToFollowingItems = (nodes, followerUsernames) => { 11 | nodes.forEach(node => { 12 | const buyLink = node.querySelector('.feed-post__coin-price-holder') 13 | if (!buyLink) return 14 | 15 | const username = buyLink.parentElement.firstElementChild.innerText.trim() 16 | if (followerUsernames.indexOf(username) < 0) return 17 | 18 | const followsYouSpan = createFollowsYouBadge() 19 | buyLink.parentElement.insertBefore(followsYouSpan, buyLink.parentElement.lastElementChild) 20 | }) 21 | } 22 | 23 | const observeFollowLists = (page) => { 24 | const loggedInPublicKey = getLoggedInPublicKey() 25 | if (!loggedInPublicKey) return 26 | 27 | const getFilteredSidNodes = (nodes) => Array.from(nodes).filter(node => node.dataset && node.dataset.sid) 28 | 29 | getFollowersByPublicKey(loggedInPublicKey).then(res => res['PublicKeyToProfileEntry']).then(followersMap => { 30 | const listDiv = page.querySelector('[ui-scroll]') 31 | if (!listDiv) return 32 | 33 | const followerValues = Object.values(followersMap) 34 | const followerUsernames = followerValues.map(follower => follower ? follower['Username'] : "") 35 | 36 | // Add to existing list items 37 | const nodes = getFilteredSidNodes(listDiv.childNodes) 38 | addFollowsYouBadgeToFollowingItems(nodes, followerUsernames) 39 | 40 | // Listen for new list items 41 | const observerConfig = { childList: true, subtree: false } 42 | const observer = new MutationObserver(mutations => { 43 | mutations.forEach(mutation => { 44 | const nodes = getFilteredSidNodes(mutation.addedNodes) 45 | addFollowsYouBadgeToFollowingItems(nodes, followerUsernames) 46 | }) 47 | }) 48 | observer.observe(listDiv, observerConfig) 49 | }) 50 | } 51 | 52 | const addBlockedUsersTabToFollows = (page) => { 53 | const loggedInPublicKey = getLoggedInPublicKey() 54 | if (!loggedInPublicKey) return 55 | 56 | const blockedUsersTabClass = 'plus-blocked-users-tab' 57 | if (document.getElementsByClassName(blockedUsersTabClass).length >= 2) return 58 | 59 | const tabSelectors = page.querySelectorAll('tab-selector') 60 | tabSelectors.forEach(tabSelector => { 61 | const tabsInner = tabSelector?.firstElementChild 62 | if (!tabsInner) return 63 | 64 | const username = getUsernameFromUrl() 65 | 66 | // Restore the click handling for the Following tab 67 | tabsInner.children.item(1).onclick = () => window.location.href = `/u/${username}/following` 68 | 69 | const tabText = document.createElement('div') 70 | tabText.className = 'd-flex h-100 align-items-center fs-15px fc-muted' 71 | tabText.innerText = 'Blocked' 72 | 73 | const tabUnderlineActive = document.createElement('div') 74 | tabUnderlineActive.className = 'tab-underline-inactive' 75 | tabUnderlineActive.style.width = '50px' 76 | 77 | const blockedUsersTab = document.createElement('div') 78 | blockedUsersTab.className = blockedUsersTabClass + ' d-flex flex-column align-items-center h-100 pl-15px pr-15px' 79 | blockedUsersTab.appendChild(tabText) 80 | blockedUsersTab.appendChild(tabUnderlineActive) 81 | 82 | blockedUsersTab.onclick = () => window.location.href = `/u/${username}/following?tab=blocked` 83 | 84 | tabsInner.appendChild(blockedUsersTab) 85 | }) 86 | } 87 | 88 | const createListFromBlockedUsers = (publicKey, users, blockList) => { 89 | const loggedInPublicKey = getLoggedInPublicKey() 90 | const isLoggedInUser = loggedInPublicKey === publicKey 91 | 92 | const profiles = users.map(user => user['ProfileEntryResponse']) 93 | profiles.sort((a, b) => a['Username'].localeCompare(b['Username'])).forEach(profile => { 94 | const container = document.createElement('div') 95 | 96 | blockList.appendChild(container) 97 | 98 | const row = document.createElement('div') 99 | row.className = 'row no-gutters px-15px border-bottom fs-15px h-100' 100 | 101 | container.appendChild(row) 102 | 103 | const blockPublicKey = profile['PublicKeyBase58Check'] 104 | const username = profile['Username'] 105 | 106 | const href = `/u/${username}?tab=posts` 107 | 108 | const outerAnchor = document.createElement('div') 109 | outerAnchor.className = 'fs-15px d-flex justify-content-left w-100 border-color-grey p-15px' 110 | 111 | row.appendChild(outerAnchor) 112 | 113 | const avatarContainer = document.createElement('div') 114 | avatarContainer.className = 'manage-follows__avatar-container' 115 | 116 | outerAnchor.appendChild(avatarContainer) 117 | 118 | const avatar = document.createElement('a') 119 | avatar.className = 'manage-follows__avatar br-12px' 120 | avatar.style.backgroundImage = `url("${getProfilePhotoUrlForPublicKey(blockPublicKey)}")` 121 | avatar.href = href 122 | 123 | avatarContainer.appendChild(avatar) 124 | 125 | const textContainer = document.createElement('div') 126 | textContainer.className = 'w-100 d-flex' 127 | 128 | outerAnchor.appendChild(textContainer) 129 | 130 | const textInner = document.createElement('div') 131 | textInner.className = 'w-100 d-flex align-items-center' 132 | 133 | textContainer.appendChild(textInner) 134 | 135 | const textAnchor = document.createElement('a') 136 | textAnchor.className = 'fc-default font-weight-bold flex-grow-1 py-2' 137 | textAnchor.innerText = ' ' + username 138 | textAnchor.href = href 139 | 140 | textInner.appendChild(textAnchor) 141 | 142 | if (isLoggedInUser) { 143 | const buttonContainer = document.createElement('div') 144 | buttonContainer.className = 'ml-auto' 145 | 146 | textInner.appendChild(buttonContainer) 147 | 148 | const button = document.createElement('button') 149 | button.className = 'btn btn-sm btn-danger' 150 | button.innerText = 'Unblock' 151 | button.onclick = () => { 152 | button.classList.remove('btn-danger') 153 | button.classList.add('btn-outline-secondary') 154 | button.disabled = true 155 | pendingBlockUser = { 156 | PublicKeyBase58Check: publicKey, 157 | BlockPublicKeyBase58Check: blockPublicKey, 158 | Unblock: true 159 | } 160 | blockJwtMsgId = uuid() 161 | getJwt(blockJwtMsgId) 162 | } 163 | 164 | buttonContainer.appendChild(button) 165 | } 166 | }) 167 | } 168 | 169 | const addBlockedUsersList = (page) => { 170 | const blockListId = 'plus-blocked-users-list' 171 | if (document.getElementById(blockListId)) return 172 | 173 | const activeTabs = document.querySelectorAll('.tab-underline-active') 174 | activeTabs.forEach(activeTab => { 175 | activeTab.className = 'tab-underline-inactive' 176 | }) 177 | 178 | const tabs = document.querySelectorAll('.plus-blocked-users-tab > .tab-underline-inactive') 179 | tabs.forEach(tab => { 180 | tab.className = 'tab-underline-active' 181 | }) 182 | 183 | const listDiv = page.querySelector('[ui-scroll]') 184 | if (!listDiv) return 185 | 186 | const listParent = listDiv.parentElement 187 | listDiv.remove() 188 | 189 | const footer = page.querySelector('.global__bottom-bar-mobile-height') 190 | 191 | const blockList = document.createElement('div') 192 | blockList.id = blockListId 193 | listParent.insertBefore(blockList, footer) 194 | 195 | const username = getUsernameFromUrl() 196 | getProfileByUsername(username) 197 | .then(profile => { 198 | const publicKey = profile['PublicKeyBase58Check'] 199 | return getUserMetadata(publicKey) 200 | .then(userMetadata => { 201 | const blockedPubKeys = userMetadata['BlockedPubKeys'] 202 | if (blockedPubKeys && !isEmpty(blockedPubKeys)) { 203 | return getUsers(Object.keys(blockedPubKeys)) 204 | } 205 | return new Error(`No blocked users found`) 206 | }) 207 | .then(users => createListFromBlockedUsers(publicKey, users, blockList)) 208 | }) 209 | .catch(console.error) 210 | } 211 | 212 | const blockUser = (jwt) => { 213 | if (!pendingBlockUser) return 214 | 215 | blockPublicKey(pendingBlockUser, jwt).catch(console.error) 216 | 217 | blockJwtMsgId = null 218 | pendingBlockUser = {} 219 | } -------------------------------------------------------------------------------- /lib/cloutmask.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) Paul Burke 2021 3 | Github: @ipaulpro/bitcloutplus 4 | Distributed under the MIT License (license terms are at http://opensource.org/licenses/MIT). 5 | */ 6 | 7 | "use strict" 8 | 9 | let isLookingUpHasPressedMaskButton 10 | 11 | const hasPressedMaskButton = () => new Promise((resolve) => { 12 | isLookingUpHasPressedMaskButton = true 13 | chrome.storage.local.get(['hasPressedMaskButton'], items => { 14 | isLookingUpHasPressedMaskButton = false 15 | if (chrome.runtime.lastError) return resolve(false) 16 | resolve(items.hasPressedMaskButton) 17 | }) 18 | }) 19 | 20 | const setHasPressedMaskButton = () => { 21 | chrome.storage.local.set({hasPressedMaskButton: true}) 22 | } 23 | 24 | const isMaskedUser = (identityUser) => { 25 | return identityUser && identityUser['isMaskedUser'] 26 | } 27 | 28 | const isLoggedInAsMaskedUser = () => { 29 | const loggedInIdentityUser = getCurrentIdentity() 30 | return loggedInIdentityUser && isMaskedUser(loggedInIdentityUser) 31 | } 32 | 33 | const getMaskedIdentityUsers = () => { 34 | const identityUsers = getIdentityUsers() 35 | if (!identityUsers) return 36 | 37 | const maskedUsers = [] 38 | for (const key in identityUsers) { 39 | const identityUser = identityUsers[key] 40 | if (identityUser.isMaskedUser) maskedUsers.push(identityUser) 41 | } 42 | return maskedUsers 43 | } 44 | 45 | const addPublicKeyToIdentityUsers = (key) => { 46 | const identityUsers = getIdentityUsers() 47 | if (!identityUsers || identityUsers[key]) return 48 | 49 | const dummyUser = getCurrentIdentity() || {} 50 | dummyUser.isMaskedUser = true 51 | identityUsers[key] = dummyUser 52 | 53 | window.localStorage.setItem('identityUsersV2', JSON.stringify(identityUsers)) 54 | window.localStorage.setItem('lastLoggedInUserV2', `"${key}"`) 55 | 56 | window.location.reload() 57 | 58 | setHasPressedMaskButton() 59 | } 60 | 61 | function switchToFirstAccount(identityUsers = getIdentityUsers()) { 62 | if (!identityUsers) return 63 | const firstKey = Object.keys(identityUsers)[0] 64 | if (firstKey) window.localStorage.setItem('lastLoggedInUserV2', `"${firstKey}"`) 65 | window.location.reload() 66 | } 67 | 68 | const removePublicKeyFromIdentityUsers = (key) => { 69 | const identityUsers = getIdentityUsers() 70 | if (!identityUsers || (identityUsers[key] && !identityUsers[key]['isMaskedUser'])) return 71 | 72 | try { 73 | delete identityUsers[key] 74 | window.localStorage.setItem('identityUsersV2', JSON.stringify(identityUsers)) 75 | } catch (e) { 76 | return 77 | } 78 | 79 | if (key === getLoggedInPublicKey()) switchToFirstAccount(identityUsers) 80 | } 81 | 82 | const removeAllMaskedUsers = () => { 83 | const identityUsers = getIdentityUsers() 84 | const realUsers = {} 85 | const loggedInAsMaskedUser = isLoggedInAsMaskedUser() 86 | 87 | for (const key in identityUsers) { 88 | const identityUser = identityUsers[key] 89 | if (!identityUser.isMaskedUser) realUsers[key] = identityUser 90 | } 91 | 92 | window.localStorage.setItem('identityUsersV2', JSON.stringify(realUsers)) 93 | 94 | if (loggedInAsMaskedUser) switchToFirstAccount(identityUsers) 95 | } 96 | 97 | const createCloutMaskIconElement = () => { 98 | const iconUrl = chrome.runtime.getURL('/images/cloutmask.svg') 99 | const img = document.createElement('img') 100 | img.width = 16 101 | img.height = 16 102 | img.alt = "Mask Logo" 103 | img.src = iconUrl 104 | return img 105 | } 106 | 107 | const addCloutMaskButton = (page) => { 108 | if (isLookingUpHasPressedMaskButton || !page || page.querySelector('#__clout-mask-button')) return 109 | 110 | const publicKeyFromPage = getPublicKeyFromPage(page) 111 | if (!publicKeyFromPage) return 112 | 113 | const identityUsers = getIdentityUsers() 114 | if (!identityUsers) return 115 | 116 | const pageIdentityUser = identityUsers[publicKeyFromPage] 117 | if (pageIdentityUser && !pageIdentityUser['isMaskedUser']) return 118 | 119 | const topBar = page.querySelector('.creator-profile__top-bar') 120 | if (!topBar) return 121 | 122 | hasPressedMaskButton().then(hasPressed => { 123 | topBar.style.justifyContent = 'flex-end' 124 | topBar.style.alignItems = 'center' 125 | 126 | const maskButton = document.createElement('button') 127 | maskButton.id = '__clout-mask-button' 128 | maskButton.className = 'btn btn-sm text-muted fs-14px rounded-pill' 129 | maskButton.classList.add(hasPressed ? 'btn-dark' : 'btn-primary') 130 | 131 | const icon = createCloutMaskIconElement().outerHTML 132 | const userAddedByCloutMask = isMaskedUser(pageIdentityUser) 133 | if (userAddedByCloutMask) { 134 | maskButton.innerHTML = `${icon} Remove account` 135 | maskButton.onclick = () => removePublicKeyFromIdentityUsers(publicKeyFromPage) 136 | topBar.appendChild(maskButton) 137 | } else if (!pageIdentityUser) { 138 | maskButton.setAttribute('bs-toggle', 'tooltip') 139 | maskButton.innerHTML = icon 140 | maskButton.title = "Browse as this user" 141 | maskButton.onclick = () => addPublicKeyToIdentityUsers(publicKeyFromPage) 142 | topBar.appendChild(maskButton) 143 | } 144 | }) 145 | } 146 | 147 | const disabledClassName = '__clout-mask-disabled' 148 | 149 | const disableElement = (element) => { 150 | if (!element) return 151 | element.classList.add(disabledClassName) 152 | element.disabled = true 153 | } 154 | 155 | const reEnableElements = () => { 156 | const disabledElements = document.getElementsByClassName(disabledClassName) 157 | Array.from(disabledElements).forEach(element => { 158 | element.classList.remove(disabledClassName) 159 | element.disabled = false 160 | }) 161 | } 162 | 163 | const disableFeedPostButtons = () => { 164 | const iconRows = document.querySelectorAll('.js-feed-post-icon-row__container') 165 | iconRows.forEach((row) => { 166 | const postButtons = Array.from(row.children) 167 | postButtons.splice(postButtons.length - 1, 1) 168 | postButtons.forEach(disableElement) 169 | }) 170 | } 171 | 172 | const disableKnownLinks = () => { 173 | const anchors = document.querySelectorAll( 174 | "a[href*='/buy'], a[href*='/sell'], a[href*='/transfer'], a[href*='/select-creator-coin'], a[href*='/send-deso'], a[href*='/settings'], a[href*='/buy-deso'], a[href*='/admin'], a[href*='/nft-transfers']" 175 | ) 176 | anchors.forEach(disableElement) 177 | } 178 | 179 | const disableClasses = () => { 180 | const elements = document.querySelectorAll( 181 | '.feed-create-post__textarea, .update-profile__image-delete, feed-post-dropdown, app-update-profile-page .btn-primary' 182 | ) 183 | elements.forEach(disableElement) 184 | } 185 | 186 | const disablePostButtons = () => { 187 | const createPostElement = document.querySelector('feed-create-post') 188 | if (createPostElement) { 189 | const postButton = createPostElement.querySelector('.btn-primary') 190 | disableElement(postButton) 191 | } 192 | } 193 | 194 | const disableFollowButtons = (mutationsList) => { 195 | for (const mutation of mutationsList) { 196 | if (mutation.type === 'childList') { 197 | const node = mutation.target 198 | if ((node.innerText === 'Unfollow' || node.innerText === 'Follow')) { 199 | disableElement(node) 200 | } 201 | } 202 | } 203 | } 204 | 205 | const addMaskToAccountSelector = () => { 206 | const accountSelectorMaskIconId = '__clout-mask-account-selector-icon' 207 | if (document.getElementById(accountSelectorMaskIconId)) return 208 | 209 | const accountName = document.querySelector('.change-account-selector__ellipsis-restriction') 210 | if (!accountName) return 211 | 212 | const icon = createCloutMaskIconElement() 213 | icon.id = accountSelectorMaskIconId 214 | icon.classList.add('mr-2') 215 | 216 | accountName.innerHTML = `${icon.outerHTML} ${accountName.innerText}` 217 | } 218 | 219 | const addClearAllToAccountSelector = () => { 220 | const id = '__clout-mask-clear-all-item' 221 | if (document.getElementById(id)) return 222 | 223 | const accountsList = document.querySelector('.change-account-selector_list') 224 | if (!accountsList || accountsList.classList.contains('change-account-selector__hover')) return 225 | 226 | const div = document.createElement('div') 227 | div.id = id 228 | div.innerHTML = `` 229 | div.onclick = removeAllMaskedUsers 230 | 231 | accountsList.appendChild(div) 232 | } 233 | 234 | const cloutMaskAppRootObserverCallback = (mutationsList) => { 235 | if (isLoggedInAsMaskedUser()) { 236 | disableFollowButtons(mutationsList) 237 | disableFeedPostButtons() 238 | disableKnownLinks() 239 | disableClasses() 240 | disablePostButtons() 241 | } else { 242 | reEnableElements() 243 | } 244 | 245 | if (getMaskedIdentityUsers().length > 0) { 246 | addClearAllToAccountSelector() 247 | } 248 | 249 | const profilePage = document.querySelector('creator-profile-page') 250 | if (profilePage) { 251 | addCloutMaskButton(profilePage) 252 | } 253 | } 254 | 255 | const cloutMaskBodyObserverCallback = () => { 256 | if (isLoggedInAsMaskedUser()) { 257 | addMaskToAccountSelector() 258 | } 259 | } 260 | 261 | const initCloutMask = () => { 262 | const appRoot = document.querySelector('app-root') 263 | if (appRoot) { 264 | const appRootObserverConfig = {childList: true, subtree: true} 265 | const appRootObserver = new MutationObserver(cloutMaskAppRootObserverCallback) 266 | appRootObserver.observe(appRoot, appRootObserverConfig) 267 | } 268 | 269 | const body = document.querySelector('body') 270 | if (body) { 271 | const bodyObserverConfig = { childList: true, subtree: false } 272 | const bodyObserver = new MutationObserver(cloutMaskBodyObserverCallback) 273 | bodyObserver.observe(body, bodyObserverConfig) 274 | } 275 | } 276 | 277 | initCloutMask() 278 | -------------------------------------------------------------------------------- /lib/api.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) Paul Burke 2021 3 | Github: @ipaulpro/bitcloutplus 4 | Distributed under the MIT License (license terms are at http://opensource.org/licenses/MIT). 5 | */ 6 | 7 | const MIN_FEE_RATE_NANOS_PER_KB = 1000 8 | 9 | let searchAbortController 10 | 11 | let apiBaseUrl 12 | 13 | const getBaseUrl = () => { 14 | if (!apiBaseUrl) { 15 | const node = window.localStorage.getItem('lastLocalNodeV2') 16 | if (!node) apiBaseUrl = `https://${window.location.hostname}` 17 | apiBaseUrl = `https://${node.replace(/['"]+/g, '')}` 18 | } 19 | return apiBaseUrl 20 | } 21 | 22 | const buildRequest = (credentials) => { 23 | return { 24 | 'headers': { 25 | 'accept': 'application/json, text/plain, */*', 26 | 'content-type': 'application/json', 27 | }, 28 | 'referrerPolicy': 'no-referrer', 29 | 'method': 'POST', 30 | 'mode': 'cors', 31 | 'credentials': credentials 32 | } 33 | } 34 | 35 | const getUserMetadata = (publicKey) => { 36 | if (!publicKey) return Promise.reject('Missing required parameter publicKey') 37 | 38 | // get-user-metadata is not exposed on bitclout.com/api 39 | return fetch(`https://node.deso.org/api/v0/get-user-metadata/${publicKey}`) 40 | .then(res => res.json()) 41 | } 42 | 43 | const getUsers = function (publicKeys) { 44 | if (!publicKeys) return Promise.reject('Missing required parameter publicKeys') 45 | 46 | const request = buildRequest('include') 47 | request.body = JSON.stringify({ 48 | PublicKeysBase58Check: publicKeys, 49 | SkipForLeaderboard: true 50 | }) 51 | 52 | return fetch(`${getBaseUrl()}/api/v0/get-users-stateless`, request) 53 | .then(res => res.json()) 54 | .then(res => res['UserList']) 55 | } 56 | 57 | const getProfileByUsername = function (username) { 58 | if (!username) return Promise.reject('Missing required parameter username') 59 | 60 | const request = buildRequest('include') 61 | request.body = JSON.stringify({ 62 | Username: username 63 | }) 64 | 65 | return fetch(`${getBaseUrl()}/api/v0/get-single-profile`, request) 66 | .then(res => res.json()) 67 | .then(res => res['Profile']) 68 | } 69 | 70 | const getProfileByPublicKey = function (publicKey) { 71 | if (!publicKey) return Promise.reject('Missing required parameter publicKey') 72 | 73 | const request = buildRequest('include') 74 | request.body = JSON.stringify({ 75 | PublicKeyBase58Check: publicKey 76 | }) 77 | 78 | return fetch(`${getBaseUrl()}/api/v0/get-single-profile`, request) 79 | .then(res => res.json()) 80 | .then(res => res['Profile']) 81 | } 82 | 83 | const getFollowersByPublicKey = function (pubKey) { 84 | if (!pubKey) return Promise.reject('Missing required parameter pubKey') 85 | 86 | const request = buildRequest('include') 87 | request.body = JSON.stringify({ 88 | PublicKeyBase58Check: pubKey, 89 | GetEntriesFollowingUsername: true, 90 | NumToFetch: 20000 91 | }) 92 | 93 | return fetch(`${getBaseUrl()}/api/v0/get-follows-stateless`, request) 94 | .then(res => res.json()) 95 | } 96 | 97 | const getFollowingByPublicKey = function (pubKey) { 98 | if (!pubKey) return Promise.reject('Missing required parameter pubKey') 99 | 100 | const request = buildRequest('include') 101 | request.body = JSON.stringify({ 102 | PublicKeyBase58Check: pubKey, 103 | GetEntriesFollowingUsername: false, 104 | NumToFetch: 20000 105 | }) 106 | 107 | return fetch(`${getBaseUrl()}/api/v0/get-follows-stateless`, request) 108 | .then(res => res.json()) 109 | } 110 | 111 | const getHodlersByUsername = function (username) { 112 | if (!username) return Promise.reject('Missing required parameter username') 113 | 114 | const readerPubKey = getLoggedInPublicKey() 115 | if (!readerPubKey) return Promise.reject('No logged in user found') 116 | 117 | const request = buildRequest('omit') 118 | request.body = JSON.stringify({ 119 | ReaderPublicKeyBase58Check: readerPubKey, 120 | username: username, 121 | NumToFetch: 10000 122 | }) 123 | 124 | return fetch(`${getBaseUrl()}/api/v0/get-hodlers-for-public-key`, request) 125 | .then(res => res.json()) 126 | .then(res => res['Hodlers']) 127 | } 128 | 129 | const submitTransaction = (transactionHex) => { 130 | if (!transactionHex) return Promise.reject('Missing required parameter transactionHex') 131 | 132 | const request = buildRequest('omit') 133 | request.body = JSON.stringify({ 134 | TransactionHex: transactionHex 135 | }) 136 | 137 | return fetch(`${getBaseUrl()}/api/v0/submit-transaction`, request) 138 | .then(res => res.json()) 139 | } 140 | 141 | const submitPost = ( 142 | pubKey, 143 | bodyText, 144 | images, 145 | videos, 146 | embedUrl, 147 | extraData, 148 | postHashHexToModify, 149 | repostedPostHashHex, 150 | parentStakeId 151 | ) => { 152 | const bodyObj = { 153 | Body: bodyText 154 | } 155 | 156 | if (images) bodyObj.ImageURLs = images 157 | 158 | if (videos) bodyObj.VideoURLs = videos 159 | 160 | const body = { 161 | UpdaterPublicKeyBase58Check: pubKey, 162 | BodyObj: bodyObj, 163 | CreatorBasisPoints: 0, 164 | StakeMultipleBasisPoints: 12500, 165 | IsHidden: false, 166 | MinFeeRateNanosPerKB: MIN_FEE_RATE_NANOS_PER_KB, 167 | PostExtraData: extraData 168 | } 169 | 170 | if (postHashHexToModify) { 171 | body['PostHashHexToModify'] = postHashHexToModify 172 | } 173 | 174 | if (repostedPostHashHex) { 175 | body['RepostedPostHashHex'] = repostedPostHashHex 176 | } 177 | 178 | if (parentStakeId) { 179 | body['ParentStakeID'] = parentStakeId 180 | } 181 | 182 | if (embedUrl) { 183 | const formattedEmbedUrl = getEmbedURL(embedUrl) 184 | if (formattedEmbedUrl) { 185 | if (!body.PostExtraData) body.PostExtraData = {} 186 | body.PostExtraData.EmbedVideoURL = formattedEmbedUrl 187 | } else { 188 | bodyObj.ImageURLs = [embedUrl] 189 | } 190 | } 191 | 192 | if (!extraData['Node']) { 193 | switch (window.location.hostname) { 194 | case 'node.deso.org': 195 | extraData['Node'] = '1' 196 | break; 197 | case 'bitclout.com': 198 | extraData['Node'] = '2' 199 | break; 200 | default: 201 | extraData['Node'] = '0' 202 | break; 203 | } 204 | } 205 | 206 | const request = buildRequest('omit') 207 | request.body = JSON.stringify(body) 208 | 209 | return fetch(`${getBaseUrl()}/api/v0/submit-post`, request) 210 | .then(res => res.json()) 211 | .then(res => { 212 | if (res['TransactionHex']) { 213 | return res['TransactionHex'] 214 | } 215 | throw new Error(res['error']) 216 | }) 217 | } 218 | 219 | const searchUsernames = function (query, cb) { 220 | if (searchAbortController) { 221 | searchAbortController.abort() 222 | } 223 | 224 | const request = buildRequest('omit') 225 | request.body = JSON.stringify({ 226 | UsernamePrefix: query, 227 | NumToFetch: 4 228 | }) 229 | 230 | searchAbortController = new AbortController() 231 | const { signal } = searchAbortController 232 | request.signal = signal 233 | 234 | return fetch(`${getBaseUrl()}/api/v0/get-profiles`, request) 235 | .then(res => res.json()) 236 | .then(res => { cb(res['ProfilesFound']) }) 237 | .catch(() => {}) 238 | } 239 | 240 | const getProfilePhotoUrlForPublicKey = (pubKey) => { 241 | return `${getBaseUrl()}/api/v0/get-single-profile-picture/${pubKey}?fallback=https://${window.location.hostname}/assets/img/default_profile_pic.png` 242 | } 243 | 244 | const isHoldingPublicKey = (publicKey, isHoldingPublicKey) => { 245 | if (!publicKey || !isHoldingPublicKey) return Promise.reject('Missing required parameter') 246 | 247 | const request = buildRequest('omit') 248 | request.body = JSON.stringify({ 249 | PublicKeyBase58Check: publicKey, 250 | IsHodlingPublicKeyBase58Check: isHoldingPublicKey 251 | }) 252 | 253 | return fetch(`${getBaseUrl()}/api/v0/is-hodling-public-key`, request) 254 | .then(res => res.json()) 255 | } 256 | 257 | const isFollowingPublicKey = (publicKey, isFollowingPublicKey) => { 258 | if (!publicKey || !isFollowingPublicKey) return Promise.reject('Missing required parameter') 259 | 260 | const request = buildRequest('omit') 261 | request.body = JSON.stringify({ 262 | PublicKeyBase58Check: publicKey, 263 | IsFollowingPublicKeyBase58Check: isFollowingPublicKey 264 | }) 265 | 266 | return fetch(`${getBaseUrl()}/api/v0/is-following-public-key`, request) 267 | .then(res => res.json()) 268 | } 269 | 270 | const getSinglePost = (postHashHex, fetchParents = false, commentLimit = 0) => { 271 | if (!postHashHex) return Promise.reject('Missing required parameter') 272 | 273 | const request = buildRequest('omit') 274 | request.body = JSON.stringify({ 275 | PostHashHex: postHashHex, 276 | ReaderPublicKeyBase58Check: getLoggedInPublicKey(), 277 | FetchParents: fetchParents, 278 | CommentLimit: commentLimit 279 | }) 280 | 281 | return fetch(`${getBaseUrl()}/api/v0/get-single-post`, request) 282 | .then(res => res.json()) 283 | } 284 | 285 | const getBidsForNftPost = (publicKey, postHashHex) => { 286 | if (!publicKey || !isFollowingPublicKey) return Promise.reject('Missing required parameter') 287 | 288 | const request = buildRequest('omit') 289 | request.body = JSON.stringify({ 290 | ReaderPublicKeyBase58Check: publicKey, 291 | PostHashHex: postHashHex 292 | }) 293 | 294 | return fetch(`${getBaseUrl()}/api/v0/get-nft-bids-for-nft-post`, request) 295 | .then(res => res.json()) 296 | } 297 | 298 | const getNftEntriesForPostHashHex = (readerPublicKey, postHashHex) => { 299 | if (!readerPublicKey || !isFollowingPublicKey) return Promise.reject('Missing required parameter') 300 | 301 | const request = buildRequest('omit') 302 | request.body = JSON.stringify({ 303 | ReaderPublicKeyBase58Check: readerPublicKey, 304 | PostHashHex: postHashHex 305 | }) 306 | 307 | return fetch(`${getBaseUrl()}/api/v0/get-nft-entries-for-nft-post`, request) 308 | .then(res => res.json()) 309 | .then(res => res['NFTEntryResponses']) 310 | } 311 | 312 | const transferNft = (senderPublicKey, receiverPublicKey, nftPostHashHex, serialNumber, encryptedUnlockableText) => { 313 | if (!senderPublicKey || !nftPostHashHex || !serialNumber) return Promise.reject('Missing required parameter') 314 | 315 | const request = buildRequest('omit') 316 | request.body = JSON.stringify({ 317 | SenderPublicKeyBase58Check: senderPublicKey, 318 | ReceiverPublicKeyBase58Check: receiverPublicKey, 319 | NFTPostHashHex: nftPostHashHex, 320 | SerialNumber: serialNumber, 321 | EncryptedUnlockableText: encryptedUnlockableText, 322 | MinFeeRateNanosPerKB: MIN_FEE_RATE_NANOS_PER_KB 323 | }) 324 | 325 | return fetch(`${getBaseUrl()}/api/v0/transfer-nft`, request) 326 | .then(res => res.json()) 327 | } 328 | 329 | const getTransactionInfo = (publicKey, lastPublicKeyTransactionIndex = -1, limit = 1) => { 330 | if (!publicKey) return Promise.reject('Missing required parameter') 331 | 332 | const request = buildRequest('omit') 333 | request.body = JSON.stringify({ 334 | PublicKeyBase58Check: publicKey, 335 | Limit: limit, 336 | LastPublicKeyTransactionIndex: lastPublicKeyTransactionIndex 337 | }) 338 | 339 | return fetch(`${getBaseUrl()}/api/v1/transaction-info`, request) 340 | .then(res => res.json()) 341 | .then(res => res['Transactions']) 342 | } 343 | 344 | const getAppState = () => { 345 | const request = buildRequest('omit') 346 | request.body = '{}' 347 | 348 | return fetch(`${getBaseUrl()}/api/v0/get-app-state`, request).then(res => res.json()) 349 | } 350 | 351 | const getBlockByHash = (blockHashHex) => { 352 | if (!blockHashHex) return Promise.reject('Missing required parameter') 353 | 354 | const request = buildRequest('omit') 355 | request.body = JSON.stringify({ 356 | HashHex: blockHashHex 357 | }) 358 | 359 | return fetch(`${getBaseUrl()}/api/v1/block`, request) 360 | .then(res => res.json()) 361 | } 362 | 363 | const getBlockByHeight = (blockHeight) => { 364 | if (!blockHeight) return Promise.reject('Missing required parameter') 365 | 366 | const request = buildRequest('omit') 367 | request.body = JSON.stringify({ 368 | FullBlock: true, 369 | Height: blockHeight 370 | }) 371 | 372 | return fetch(`${getBaseUrl()}/api/v1/block`, request) 373 | .then(res => res.json()) 374 | } 375 | 376 | const getUnreadNotificationsCount = (publicKey) => { 377 | const request = buildRequest('omit') 378 | request.body = JSON.stringify({ 379 | PublicKeyBase58Check: publicKey 380 | }) 381 | 382 | return fetch(`${getBaseUrl()}/api/v0/get-unread-notifications-count`, request) 383 | .then(res => res.json()) 384 | } 385 | 386 | const setNotificationMetadata = (metadata) => { 387 | const request = buildRequest('omit') 388 | request.body = JSON.stringify(metadata) 389 | 390 | return fetch(`${getBaseUrl()}/api/v0/set-notification-metadata`, request) 391 | } 392 | 393 | const blockPublicKey = (pendingBlockUser, jwt) => { 394 | const request = buildRequest('omit') 395 | request.body = JSON.stringify({ 396 | ...pendingBlockUser, 397 | JWT: jwt 398 | }) 399 | return fetch(`${getBaseUrl()}/api/v0/block-public-key`, request) 400 | .then(res => res.json()) 401 | } -------------------------------------------------------------------------------- /lib/post.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) Paul Burke 2021 3 | Github: @ipaulpro/bitcloutplus 4 | Distributed under the MIT License (license terms are at http://opensource.org/licenses/MIT). 5 | */ 6 | 7 | const maxPostLength = 15000 8 | const postButtonClass = 'plus-btn-submit-post' 9 | 10 | const extraDataTextAreaId = 'plus_create-post-extra-data' 11 | const extraDataHelpTextId = 'plus_create-post-extra-data-help' 12 | const editPostId = 'plus_edit-post-btn' 13 | 14 | const addNewPostButton = function () { 15 | let addPostButtonId = 'plus-add-new-post' 16 | if (document.getElementById(addPostButtonId)) return 17 | 18 | const globalNavElements = document.getElementsByClassName('global__nav__inner') 19 | try { 20 | const globalNav = globalNavElements.item(0) 21 | 22 | const button = document.createElement('button'); 23 | button.id = addPostButtonId 24 | button.type = 'button' 25 | button.className = 'btn btn-secondary font-weight-bold fs-14px ml-3' 26 | button.innerText = 'Create Post' 27 | button.onclick = () => window.location.href = 'posts/new' 28 | 29 | const div = document.createElement('div') 30 | div.className = 'w-100 d-flex pt-3 pl-4 pr-2 pb-4' 31 | div.appendChild(button) 32 | 33 | globalNav.appendChild(div) 34 | } catch (e) {} 35 | } 36 | 37 | const buildTributeUsernameMenuTemplate = item => { 38 | const spotPrice = getSpotPrice() 39 | const bitcloutPrice = item.original['CoinPriceDeSoNanos'] / deSoInNanos 40 | 41 | const priceDiv = document.createElement('div') 42 | priceDiv.className = 'plus-text-muted fs-12px' 43 | priceDiv.innerText = `${dollarFormatter.format(spotPrice * bitcloutPrice)}` 44 | 45 | const verifiedIcon = document.createElement('i') 46 | verifiedIcon.className = 'fas fa-check-circle fa-md ml-1 plus-text-primary' 47 | 48 | const reservedIcon = document.createElement('i') 49 | reservedIcon.className = 'far fa-clock fa-md ml-1 plus-text-muted' 50 | 51 | let icon 52 | if (item.original['IsVerified']) { 53 | icon = verifiedIcon 54 | } else if (item.original['IsReserved']) { 55 | icon = reservedIcon 56 | } 57 | 58 | const usernameSpan = document.createElement('span') 59 | usernameSpan.innerText = item.original['Username'] 60 | if (icon) usernameSpan.appendChild(icon) 61 | 62 | const nameDiv = document.createElement('div') 63 | nameDiv.className = 'ml-1 pl-1' 64 | nameDiv.appendChild(usernameSpan) 65 | nameDiv.appendChild(priceDiv) 66 | 67 | const pubKey = item.original['PublicKeyBase58Check'] 68 | const img = document.createElement('img') 69 | img.className = 'tribute-avatar' 70 | img.src = getProfilePhotoUrlForPublicKey(pubKey) 71 | 72 | const row = document.createElement('div') 73 | row.className = 'row no-gutters' 74 | row.appendChild(img) 75 | row.appendChild(nameDiv) 76 | 77 | return row.outerHTML 78 | } 79 | 80 | function buildLoadingItemTemplate () { 81 | return `
Loading...
` 82 | } 83 | 84 | const addPostUsernameAutocomplete = function () { 85 | const createPostInputs = document.getElementsByClassName('cdk-textarea-autosize') 86 | for (let input of createPostInputs) { 87 | if (input.dataset && !input.dataset.tribute) { 88 | const tribute = new Tribute({ 89 | values: (text, cb) => searchUsernames(text, users => cb(users)), 90 | menuItemTemplate: (item) => buildTributeUsernameMenuTemplate(item), 91 | loadingItemTemplate: buildLoadingItemTemplate(), 92 | fillAttr: 'Username', 93 | lookup: 'Username' 94 | }) 95 | tribute.attach(input) 96 | } 97 | } 98 | } 99 | 100 | const restorePostDraft = () => { 101 | chrome.storage.local.get(['postDraft'], items => { 102 | const postDraft = items.postDraft 103 | if (postDraft) { 104 | const createPostTextArea = document.querySelector('.feed-create-post__textarea') 105 | if (createPostTextArea) { 106 | createPostTextArea.value = postDraft 107 | chrome.storage.local.remove(['postDraft']) 108 | } 109 | } 110 | }) 111 | } 112 | 113 | const getPostButton = (container) => { 114 | const plusButton = container.querySelector(`.${postButtonClass}`) 115 | if (plusButton) return plusButton 116 | 117 | const primaryButtons = container.querySelectorAll('.btn-primary') 118 | let postButton 119 | for (let primaryButton of primaryButtons) { 120 | if (primaryButton.innerText.includes('Post')) { 121 | postButton = primaryButton 122 | break 123 | } 124 | } 125 | return postButton 126 | } 127 | 128 | const disableLongPost = () => { 129 | const container = document.querySelector('feed-create-post') 130 | if (!container) return 131 | 132 | const postTextArea = container.querySelector('textarea') 133 | if (!postTextArea) return 134 | 135 | chrome.storage.local.set({ 136 | longPost: false, 137 | postDraft: postTextArea.value 138 | }) 139 | window.location.reload(true) 140 | } 141 | 142 | const addPostErrorDiv = (e, container) => { 143 | const btn = document.createElement('button') 144 | btn.className = 'btn btn-danger btn-sm mt-2' 145 | btn.innerText = 'Disable post enhancements' 146 | btn.onclick = () => disableLongPost() 147 | 148 | const textarea = document.createElement('textarea') 149 | textarea.className = 'w-100' 150 | textarea.rows = 6 151 | textarea.innerText = `${(e.stack || e)}` 152 | 153 | const span = document.createElement('span') 154 | span.innerText = 'Trouble posting? Disabling post enhancements may help.' 155 | 156 | const a = document.createElement('a') 157 | a.href = '/u/plus' 158 | a.innerText = '@plus' 159 | 160 | const contact = document.createElement('span') 161 | contact.className = 'd-block my-2' 162 | contact.innerText = 'Please report this to ' 163 | contact.appendChild(a) 164 | 165 | const p = document.createElement('p') 166 | p.className = 'plus-text-muted fs-14px' 167 | p.appendChild(span) 168 | p.appendChild(contact) 169 | p.appendChild(textarea) 170 | 171 | const div = document.createElement('div') 172 | div.className = 'p-2' 173 | 174 | div.appendChild(p) 175 | div.appendChild(btn) 176 | container.appendChild(div) 177 | } 178 | 179 | const onPostButtonClick = (postButton) => { 180 | if (!postButton) return 181 | 182 | const restoreButton = () => { 183 | postButton.classList.remove('disabled') 184 | postButton.innerText = 'Post' 185 | } 186 | 187 | const container = document.querySelector('feed-create-post') 188 | if (!container) return 189 | 190 | const postTextArea = container.querySelector('textarea') 191 | if (!postTextArea) return 192 | 193 | const postBody = postTextArea.value 194 | if (!postBody) return 195 | 196 | postButton.classList.add('disabled') 197 | 198 | const spinnerAlt = document.createElement('span') 199 | spinnerAlt.className = 'sr-only' 200 | spinnerAlt.innerText = 'Working...' 201 | 202 | const spinner = document.createElement('div') 203 | spinner.className = 'spinner-border spinner-border-sm text-light' 204 | spinner.dataset.role = 'status' 205 | spinner.appendChild(spinnerAlt) 206 | 207 | postButton.innerText = '' 208 | postButton.appendChild(spinner) 209 | 210 | const onPostSubmitted = (transactionHex) => { 211 | if (!transactionHex) { 212 | return Promise.reject('Error creating submit-post transaction') 213 | } 214 | 215 | const identity = getCurrentIdentity() 216 | if (!identity) { 217 | return Promise.reject('No Identity found') 218 | } 219 | 220 | const id = uuid() 221 | sendSignTransactionMsg(identity, transactionHex, id) 222 | } 223 | 224 | const pubKey = getLoggedInPublicKey() 225 | 226 | const params = new URLSearchParams(window.location.search) 227 | const postHashHexToModify = params.get('edit') 228 | if (postHashHexToModify) { 229 | getSinglePost(postHashHexToModify) 230 | .then(data => data['PostFound']) 231 | .then(post => { 232 | if (pubKey !== post['PosterPublicKeyBase58Check']) return Promise.reject('Cannot edit a post you did not create') 233 | const image = post['ImageURLs'] 234 | const video = post['VideoURLs'] 235 | const extraData = post['PostExtraData'] 236 | if (extraData['DerivedPublicKey']) delete extraData['DerivedPublicKey'] 237 | const repostedPostHashHex = post['RepostedPostEntryResponse'] ? post['RepostedPostEntryResponse']['PostHashHex'] : '' 238 | const parentStakeID = post['ParentStakeID'] 239 | return submitPost(pubKey, postBody, image, video, null, extraData, postHashHexToModify, repostedPostHashHex, parentStakeID) 240 | }) 241 | .then(onPostSubmitted) 242 | .catch(e => { 243 | addPostErrorDiv(e, container) 244 | restoreButton() 245 | }) 246 | return 247 | } 248 | 249 | const postImage = container.getElementsByClassName('feed-post__image').item(0) 250 | const hasImage = postImage && postImage.src && 251 | (postImage.src.includes(`images.${window.location.hostname}`) || postImage.src.includes(`images.deso.org`)) 252 | const image = hasImage ? postImage.src : undefined 253 | 254 | const postEmbed = container.querySelector('input[type="url"]') 255 | const embedUrl = postEmbed ? postEmbed.value : undefined 256 | 257 | const video = container.querySelector('.feed-post__video-container')?.firstElementChild?.src 258 | 259 | let extraData = {} 260 | const extraDataTextArea = document.getElementById(extraDataTextAreaId) 261 | if (extraDataTextArea) { 262 | const extraDataTextAreaValue = extraDataTextArea.value 263 | if (extraDataTextAreaValue && extraDataTextAreaValue.length > 0) { 264 | try { 265 | extraData = JSON.parse(extraDataTextAreaValue) 266 | } catch (e) { 267 | Swal.fire({ 268 | title: 'Error', 269 | text: 'Invalid Extra Text JSON.' 270 | }) 271 | restoreButton() 272 | return 273 | } 274 | } 275 | } 276 | 277 | submitPost(pubKey, postBody, [image], [video], embedUrl, extraData) 278 | .then(onPostSubmitted) 279 | .catch(e => { 280 | addPostErrorDiv(e, container) 281 | restoreButton() 282 | }) 283 | } 284 | 285 | const replacePostBtn = () => { 286 | if (!longPostEnabled || document.querySelector(`.${postButtonClass}`)) return 287 | 288 | const form = document.querySelector('create-post-form') || document.querySelector('feed') 289 | const container = form && form.querySelector('feed-create-post') 290 | if (!container) return 291 | 292 | const postButton = getPostButton(container) 293 | if (!postButton) return 294 | 295 | const newButton = postButton.cloneNode(true) 296 | newButton.classList.add(postButtonClass) 297 | 298 | postButton.style.display = 'none' 299 | 300 | const parent = postButton.parentElement 301 | parent.appendChild(newButton) 302 | 303 | newButton.onclick = () => onPostButtonClick(newButton) 304 | } 305 | 306 | function onPostTextAreaInputChange() { 307 | const container = document.querySelector('feed-create-post') 308 | if (!container) return 309 | 310 | const characterCounter = container.querySelector('.feed-create-post__character-counter') 311 | if (!characterCounter) return 312 | 313 | const postTextArea = container.querySelector('textarea') 314 | if (!postTextArea) return 315 | 316 | const characterCount = postTextArea.value.length 317 | 318 | const postButton = getPostButton(container) 319 | if (characterCount > 0) { 320 | postButton.classList.remove('disabled') 321 | } else { 322 | postButton.classList.add('disabled') 323 | } 324 | 325 | if (!characterCounter) return 326 | characterCounter.innerText = `${characterCount} / ${maxPostLength}` 327 | if (characterCount > maxPostLength) { 328 | characterCounter.classList.add('plus-text-red') 329 | characterCounter.classList.remove('text-grey8A') 330 | characterCounter.classList.remove('text-warning') 331 | } else if (characterCount > 280) { 332 | characterCounter.classList.remove('plus-text-red') 333 | characterCounter.classList.remove('text-grey8A') 334 | characterCounter.classList.add('text-warning') 335 | } else { 336 | characterCounter.classList.remove('plus-text-red') 337 | characterCounter.classList.add('text-grey8A') 338 | characterCounter.classList.remove('text-warning') 339 | } 340 | } 341 | 342 | const addPostTextAreaListener = () => { 343 | if (!longPostEnabled) return 344 | 345 | const container = document.querySelector('feed-create-post') 346 | if (!container) return 347 | 348 | const postTextArea = container.querySelector('textarea') 349 | if (!postTextArea) return 350 | 351 | postTextArea.addEventListener('input', () => { 352 | onPostTextAreaInputChange(postTextArea) 353 | }) 354 | } 355 | 356 | const enrichCreatePostPage = (page) => { 357 | if (!page) return 358 | 359 | const feedCreatePost = page.querySelector('feed-create-post') 360 | if (!feedCreatePost) return 361 | 362 | const postEmbedContainer = feedCreatePost.lastElementChild 363 | if (!postEmbedContainer) return 364 | 365 | const icons = postEmbedContainer.querySelectorAll('i') 366 | const firstIcon = icons.length > 0 ? icons[0] : null 367 | if (!firstIcon) return 368 | 369 | const textAreaContainerId = 'plus_create-post-extra-data-box' 370 | 371 | const icon = document.createElement('i') 372 | icon.className = 'fas fa-sitemap fa-lg text-grey8A cursor-pointer fs-18px pr-15px' 373 | icon.onclick = () => { 374 | const textArea = document.getElementById(textAreaContainerId) 375 | if (!textArea) return 376 | 377 | if (textArea.classList.contains('d-none')) { 378 | textArea.classList.remove('d-none') 379 | } else { 380 | textArea.classList.add('d-none') 381 | } 382 | } 383 | postEmbedContainer.insertBefore(icon, firstIcon) 384 | 385 | const textarea = document.createElement('textarea') 386 | textarea.id = extraDataTextAreaId 387 | textarea.rows = 5 388 | textarea.spellcheck = false 389 | textarea.className = 'form-control fs-14px' 390 | textarea.placeholder = '{\n "key": "value" // Values may only be strings\n}' 391 | textarea.setAttribute('aria-describedby', extraDataHelpTextId) 392 | 393 | tabOverride.tabSize(4) 394 | tabOverride.autoIndent(true) 395 | tabOverride.set(textarea) 396 | 397 | const label = document.createElement('label') 398 | label.setAttribute('for', extraDataTextAreaId) 399 | label.innerText = 'Extra Data' 400 | 401 | const helpText = document.createElement('small') 402 | helpText.id = extraDataHelpTextId 403 | helpText.className = 'form-text text-muted' 404 | helpText.innerText = 'Extra data allows arbitrary text to be attached to the post, in JSON format. This is useful for things like on-chain NFT attributes.' 405 | 406 | const textAreaContainer = document.createElement('div') 407 | textAreaContainer.id = textAreaContainerId 408 | textAreaContainer.className = 'p-3 d-none' 409 | textAreaContainer.appendChild(label) 410 | textAreaContainer.appendChild(textarea) 411 | textAreaContainer.appendChild(helpText) 412 | 413 | feedCreatePost.insertBefore(textAreaContainer, postEmbedContainer) 414 | } 415 | 416 | const showEditPostButtonIfNeeded = () => { 417 | if (document.getElementById(editPostId)) return 418 | let postHashHex = getPostHashHexFromUrl() 419 | getSinglePost(postHashHex) 420 | .then(data => data['PostFound']) 421 | .then(post => { 422 | if (document.getElementById(editPostId)) return 423 | if (post['PosterPublicKeyBase58Check'] === getLoggedInPublicKey()) { 424 | const hasParent = post['ParentStakeID'].length > 0 425 | const a = document.createElement('a') 426 | a.id = editPostId 427 | a.className = 'fs-14px' 428 | a.href = `/posts/new?edit=${postHashHex}` 429 | a.innerText = hasParent ? 'Edit Comment' : 'Edit Post' 430 | const topBar = document.querySelector(`.global__top-bar`) 431 | topBar.appendChild(a) 432 | } 433 | }) 434 | .catch(console.error) 435 | } 436 | 437 | const checkForEditPostQueryParams = (page) => { 438 | const params = new URLSearchParams(window.location.search) 439 | const postHashHex = params.get('edit') 440 | if (postHashHex) { 441 | getSinglePost(postHashHex) 442 | .then(data => data['PostFound']) 443 | .then(post => { 444 | if (post['PosterPublicKeyBase58Check'] === getLoggedInPublicKey()) { 445 | const topBar = page.querySelector('create-post-form')?.firstElementChild 446 | const hasParent = post['ParentStakeID'].length > 0 447 | if (topBar && topBar.firstElementChild) { 448 | topBar.firstElementChild.innerText = hasParent ? ' Edit comment ' : ' Edit post ' 449 | } 450 | 451 | const createPostDiv = page.querySelector('feed-create-post') 452 | const textArea = createPostDiv?.querySelector('textarea') 453 | if (textArea) { 454 | textArea.value = post['Body'] 455 | onPostTextAreaInputChange() 456 | 457 | const postAttachmentButtons = createPostDiv.lastElementChild.querySelectorAll('i') 458 | postAttachmentButtons.forEach(button => { 459 | button.remove() 460 | }) 461 | 462 | const postButton = document.getElementsByClassName(postButtonClass)[0] 463 | postButton.innerText = 'Update' 464 | 465 | const text = document.createElement('span') 466 | text.className = 'flex-grow-1 fs-12px text-muted px-3' 467 | text.innerText = 'Attachments and extra data will remain unchanged' 468 | const container = createPostDiv.lastElementChild 469 | container.insertBefore(text, container.firstElementChild) 470 | } 471 | } 472 | }) 473 | .catch(console.error) 474 | } 475 | } -------------------------------------------------------------------------------- /lib/profile.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) Paul Burke 2021 3 | Github: @ipaulpro/bitcloutplus 4 | Distributed under the MIT License (license terms are at http://opensource.org/licenses/MIT). 5 | */ 6 | 7 | const openInNewTab = url => { 8 | window.open(url, '_blank').focus() 9 | } 10 | 11 | function fixImageLightbox(modalContainer) { 12 | const feedPostImage = modalContainer.querySelector('feed-post-image-modal') 13 | if (feedPostImage) { 14 | const content = modalContainer.querySelector('.modal-content') 15 | content.style.width = 'auto' 16 | content.style.margin = '0 auto' 17 | 18 | const dialog = modalContainer.querySelector('.modal-dialog') 19 | dialog.style.maxWidth = '1140px' 20 | } 21 | } 22 | 23 | const createMenuItem = (id, iconClassName, title) => { 24 | const icon = document.createElement('i') 25 | icon.className = `fas ${iconClassName}` 26 | 27 | const text = document.createElement('span') 28 | text.innerText = ` ${title}` 29 | 30 | const a = document.createElement('a') 31 | a.id = id 32 | a.className = 'dropdown-menu-item d-block p-10px feed-post__dropdown-menu-item fc-default' 33 | 34 | a.appendChild(icon) 35 | a.appendChild(text) 36 | 37 | return a 38 | } 39 | 40 | const addSendDeSoMenuItem = function (menu) { 41 | if (!menu) return 42 | 43 | let sendDeSoId = 'plus-profile-menu-send-deso' 44 | if (document.getElementById(sendDeSoId)) return 45 | 46 | 47 | try { 48 | const a = createMenuItem(sendDeSoId, 'fa-hand-holding-usd', 'Send $DESO') 49 | const publicKey = getPublicKeyFromPage() 50 | a.onclick = () => window.location.href = `send-deso?public_key=${publicKey}` 51 | menu.insertBefore(a, menu.firstElementChild) 52 | } catch (e) {} 53 | } 54 | 55 | const addInsightsMenuItem = function (menu) { 56 | if (!menu) return 57 | 58 | let sendMessageId = 'plus-profile-menu-insights' 59 | if (document.getElementById(sendMessageId)) return 60 | 61 | try { 62 | const a = createMenuItem(sendMessageId, 'fa-chart-bar', 'Insights') 63 | const username = getUsernameFromUrl() 64 | a.onclick = () => openInNewTab(`https://openprosper.com/u/${username}`) 65 | menu.insertBefore(a, menu.firstElementChild) 66 | } catch (e) {} 67 | } 68 | 69 | const addGeoMenuItem = function (menu) { 70 | if (!menu) return 71 | 72 | let walletId = 'plus-profile-menu-geo' 73 | if (document.getElementById(walletId)) return 74 | 75 | try { 76 | const a = createMenuItem(walletId, 'fa-map-marker-alt', 'View Location') 77 | const username = getUsernameFromUrl() 78 | a.onclick = () => openInNewTab(`https://desogeo.com/map?account=${username}`) 79 | menu.insertBefore(a, menu.firstElementChild) 80 | } catch (e) {} 81 | } 82 | 83 | const addNftMenuItem = function (menu) { 84 | if (!menu) return 85 | 86 | let walletId = 'plus-profile-menu-nft' 87 | if (document.getElementById(walletId)) return 88 | 89 | try { 90 | const a = createMenuItem(walletId, 'fa-store', 'Explore NFTs') 91 | const username = getUsernameFromUrl() 92 | a.onclick = () => openInNewTab(`https://${username}.nftz.zone`) 93 | menu.insertBefore(a, menu.firstElementChild) 94 | } catch (e) {} 95 | } 96 | 97 | const getProfileMenu = function () { 98 | const dropdownContainer = document.querySelector('bs-dropdown-container') 99 | if (!dropdownContainer) return undefined 100 | 101 | const menu = dropdownContainer.getElementsByClassName('dropdown-menu')[0] 102 | if (menu.firstElementChild.innerText.includes("Message User")) { 103 | return menu 104 | } 105 | return undefined 106 | } 107 | 108 | const enrichProfile = function () { 109 | let profileDetails = document.querySelector('creator-profile-details') 110 | if (!profileDetails) return 111 | 112 | const profileMenu = getProfileMenu() 113 | addGeoMenuItem(profileMenu) 114 | addNftMenuItem(profileMenu) 115 | addSendDeSoMenuItem(profileMenu) 116 | addInsightsMenuItem(profileMenu) 117 | } 118 | 119 | const addNativeCoinPriceToProfileHeader = (userDataDiv, profile) => { 120 | const nativePriceId = 'plus-profile-native-price' 121 | 122 | if (!userDataDiv || !profile || document.getElementById(nativePriceId)) return 123 | 124 | const priceContainerDiv = userDataDiv.children.item(1) 125 | if (!priceContainerDiv) return 126 | 127 | const priceDiv = priceContainerDiv.firstElementChild 128 | 129 | const coinPriceNanos = profile['CoinPriceDeSoNanos'] 130 | const nativePrice = (coinPriceNanos / deSoInNanos).toFixed(2) 131 | 132 | const tooltipAttr = document.createAttribute('data-bs-toggle') 133 | tooltipAttr.value = 'tooltip' 134 | 135 | let span = document.createElement('span') 136 | span.id = nativePriceId 137 | span.className = 'plus-text-muted mr-2 fs-14px' 138 | span.style.fontWeight = '500' 139 | span.innerText = `(${nativePrice} $DESO)` 140 | span.setAttributeNode(tooltipAttr) 141 | 142 | priceDiv.insertBefore(span, priceDiv.lastChild) 143 | } 144 | 145 | const addSellButton = function () { 146 | const sellButtonId = 'plus-profile-sell-btn' 147 | if (document.getElementById(sellButtonId)) return 148 | 149 | let topCardContainerElements = document.getElementsByClassName('js-creator-profile-top-card-container') 150 | try { 151 | if (topCardContainerElements.length > 0) { 152 | const topCardContainer = topCardContainerElements.item(0) 153 | if (topCardContainer) { 154 | let sellButton = document.createElement('a') 155 | sellButton.id = sellButtonId 156 | sellButton.href = document.location.pathname + '/sell' 157 | sellButton.innerText = 'Sell' 158 | sellButton.className = 'btn btn-secondary font-weight-bold ml-10px fs-14px' 159 | sellButton.style.width = '75px' 160 | sellButton.style.height = '36px' 161 | topCardContainerElements.item(0).appendChild(sellButton) 162 | } 163 | } 164 | } catch (e) {} 165 | } 166 | 167 | const addHoldersCount = function (holderCount) { 168 | let profileDetails = document.querySelector('creator-profile-details') 169 | if (!profileDetails) return 170 | 171 | const contentTop = profileDetails.firstElementChild 172 | if (!contentTop) return 173 | 174 | const tabContent = contentTop.lastElementChild 175 | if (!tabContent) return 176 | 177 | const creatorCoinTabHeader = tabContent.firstElementChild 178 | if (!creatorCoinTabHeader) return 179 | 180 | const holderDiv = creatorCoinTabHeader.firstElementChild 181 | if (!holderDiv || !holderDiv.innerText.includes('Holders of')) return 182 | 183 | const holderCountId = 'plus-profile-holder-count' 184 | 185 | let span 186 | const existingSpan = document.getElementById(holderCountId) 187 | if (existingSpan) { 188 | span = existingSpan 189 | } else { 190 | span = document.createElement('span') 191 | span.id = holderCountId 192 | span.className = 'fc-muted fs-16px' 193 | holderDiv.appendChild(span) 194 | } 195 | span.innerText = `(${holderCount})` 196 | } 197 | 198 | function addHolderPositionRank (node, index, userHoldsOwnCoin) { 199 | if (userHoldsOwnCoin && index === 0) return 200 | 201 | node.querySelector('.text-truncate').style.maxWidth = '160px !important' 202 | 203 | const itemId = 'plus-profile-holder-position-' + index 204 | const holderPositionClassName = 'plus-profile-holder-position' 205 | 206 | let i 207 | if (userHoldsOwnCoin) { 208 | i = index 209 | } else { 210 | i = index + 1 211 | } 212 | 213 | try { 214 | let span 215 | const existingSpan = document.getElementById(itemId) 216 | if (existingSpan) { 217 | span = existingSpan 218 | } else { 219 | span = document.createElement('span') 220 | span.id = itemId 221 | span.className = `${holderPositionClassName} fc-muted fs-14px align-items-start d-flex pl-0 pr-2 mr-1` 222 | span.style.minWidth = '2em' 223 | 224 | const avatarAndName = node.firstChild.firstChild.firstChild 225 | avatarAndName.insertBefore(span, avatarAndName.firstElementChild) 226 | } 227 | 228 | span.innerText = `${i}` 229 | } catch (e) { } 230 | } 231 | 232 | function addHolderPercentage (node, index, circulation) { 233 | try { 234 | const heldColumnItem = node.firstChild.firstChild.childNodes.item(1) 235 | const coinsHeld = parseFloat(heldColumnItem.innerText) 236 | 237 | const holderPercentageClassName = 'plus-profile-holder-share' 238 | let span 239 | const existingSpan = node.querySelector(`.${holderPercentageClassName}`) 240 | if (existingSpan) { 241 | span = existingSpan 242 | } else { 243 | span = document.createElement('span') 244 | span.className = `${holderPercentageClassName} fc-muted fs-12px ml-1` 245 | heldColumnItem.appendChild(span) 246 | } 247 | span.innerText = '(' + ((coinsHeld / circulation) * 100).toFixed(1) + '%)' 248 | } catch (e) { } 249 | } 250 | 251 | const highlightUserInHolderList = (node, loggedInUsername) => { 252 | try { 253 | const nameSpan = node.querySelector('.text-truncate') 254 | const holderUsername = nameSpan.innerText 255 | if (loggedInUsername === holderUsername) { 256 | node.className = 'light-grey-divider' 257 | } 258 | } catch (e) { } 259 | }; 260 | 261 | const addHolderEnrichments = function (coinsInCirculation) { 262 | const topCard = document.querySelector('creator-profile-top-card') 263 | const creatorProfileHodlers = document.querySelector('creator-profile-hodlers') 264 | if (!creatorProfileHodlers || observingHolders || !topCard) return 265 | const holdersList = creatorProfileHodlers.firstElementChild 266 | 267 | // Before the list loads, it has an "empty" view 268 | if (holdersList.childElementCount === 1) return 269 | 270 | const pageUsername = getUsernameFromUrl() 271 | const loggedInUsername = getLoggedInUsername() 272 | 273 | const firstHodlerNode = holdersList.childNodes.item(1) 274 | const firstHolderName = firstHodlerNode.querySelector('.text-truncate') 275 | const holdsOwnCoin = pageUsername.toLowerCase().startsWith(firstHolderName.innerText.toLowerCase()) 276 | 277 | try { 278 | // Only the first few holders items are initially loaded... 279 | const childNodes = holdersList.childNodes 280 | for (let i = 1; i < childNodes.length; i++) { 281 | const node = childNodes.item(i) 282 | if (!node.dataset) continue 283 | 284 | const index = Number(node.dataset.sid) 285 | highlightUserInHolderList(node, loggedInUsername) 286 | addHolderPositionRank(node, index, holdsOwnCoin) 287 | addHolderPercentage(node, index, coinsInCirculation) 288 | } 289 | } catch (e) { } 290 | 291 | // observe the rest 292 | const config = { childList: true, subtree: false } 293 | new MutationObserver((mutations) => { 294 | mutations.forEach(mutation => { 295 | Array.from(mutation.addedNodes, node => { 296 | const index = Number(node.dataset.sid) 297 | highlightUserInHolderList(node, loggedInUsername) 298 | addHolderPositionRank(node, index, holdsOwnCoin) 299 | addHolderPercentage(node, index, coinsInCirculation) 300 | }) 301 | }) 302 | }).observe(holdersList, config) 303 | observingHolders = true 304 | } 305 | 306 | const createFollowsYouBadge = (id) => { 307 | const text = document.createElement('span') 308 | text.className = 'plus-tooltip-text' 309 | text.innerText = 'Follows you' 310 | 311 | const icon = document.createElement('i') 312 | icon.className = 'fas fa-user-friends' 313 | icon.appendChild(text) 314 | 315 | const followsYouSpan = document.createElement('span') 316 | if (id) followsYouSpan.id = id 317 | followsYouSpan.className = 'badge badge-pill plus-badge plus-badge-icon ml-2 global__tooltip-icon plus-tooltip' 318 | followsYouSpan.appendChild(icon) 319 | 320 | return followsYouSpan 321 | } 322 | 323 | const addFollowsYouBadgeToProfileHeader = function (userDataDiv, following) { 324 | const followsYouBadgeId = 'plus-profile-follows-you-badge' 325 | const alreadyAdded = document.getElementById(followsYouBadgeId) 326 | 327 | if (alreadyAdded || !userDataDiv || !following) return 328 | 329 | const usernameDiv = userDataDiv.firstElementChild 330 | if (!usernameDiv) return 331 | 332 | const followsYouSpan = createFollowsYouBadge(followsYouBadgeId) 333 | usernameDiv.appendChild(followsYouSpan) 334 | } 335 | 336 | const addHodlerBadgeToProfileHeader = function (userDataDiv, isHolding, balanceEntry) { 337 | const holderBadgeId = 'plus-profile-holder-badge' 338 | const alreadyAdded = document.getElementById(holderBadgeId); 339 | if (alreadyAdded || !userDataDiv || !isHolding) return 340 | 341 | const usernameDiv = userDataDiv.firstElementChild 342 | if (!usernameDiv) return 343 | 344 | const holding = balanceEntry['BalanceNanos'] / deSoInNanos 345 | const holdsOrPurchased = balanceEntry['HasPurchased'] ? 'Purchased' : 'Gifted' 346 | const formattedHoldings = parseFloat(holding.toFixed(6)) 347 | if (formattedHoldings === 0) return 348 | 349 | const text = document.createElement('span') 350 | text.className = 'plus-tooltip-text' 351 | text.innerText = `${holdsOrPurchased} ${formattedHoldings} of your coin` 352 | 353 | const icon = document.createElement('i') 354 | icon.className = 'fas fa-coins' 355 | icon.appendChild(text) 356 | 357 | const isHodlerSpan = document.createElement('span') 358 | isHodlerSpan.id = holderBadgeId 359 | isHodlerSpan.className = 'badge badge-pill plus-badge plus-badge-icon ml-2 global__tooltip-icon plus-tooltip' 360 | isHodlerSpan.appendChild(icon) 361 | 362 | usernameDiv.appendChild(isHodlerSpan) 363 | } 364 | 365 | const addJumioBadgeToProfileHeader = (userDataDiv) => { 366 | const holderBadgeId = 'plus-profile-jumio-badge' 367 | const alreadyAdded = document.getElementById(holderBadgeId); 368 | if (alreadyAdded || !userDataDiv) return 369 | 370 | const usernameDiv = userDataDiv.firstElementChild 371 | if (!usernameDiv) return 372 | 373 | const text = document.createElement('span') 374 | text.className = 'plus-tooltip-text' 375 | text.innerText = `Verified ID with Jumio` 376 | 377 | const icon = document.createElement('i') 378 | icon.className = 'fas fa-id-card' 379 | icon.appendChild(text) 380 | 381 | const isVerifiedSpan = document.createElement('span') 382 | isVerifiedSpan.id = holderBadgeId 383 | isVerifiedSpan.className = 'badge badge-pill plus-badge plus-badge-icon ml-2 global__tooltip-icon plus-tooltip' 384 | isVerifiedSpan.appendChild(icon) 385 | 386 | usernameDiv.appendChild(isVerifiedSpan) 387 | } 388 | 389 | const getProfileUserDataDiv = function () { 390 | const topCard = document.querySelector('creator-profile-top-card') 391 | if (!topCard) return undefined 392 | 393 | const topCardContent = topCard.firstElementChild 394 | if (!topCardContent) return undefined 395 | 396 | const children = topCardContent.children 397 | if (!children || children.length < 4) return undefined 398 | 399 | return children.item(3) 400 | } 401 | 402 | const profileTabsObserver = new MutationObserver(mutations => { 403 | if (document.querySelector('creator-profile-hodlers')) { 404 | enrichProfileFromApi(mutations[0].target) 405 | } 406 | }) 407 | 408 | const observeProfileDetails = (profileDetailsDiv) => { 409 | const observerConfig = { childList: true, subtree: false } 410 | profileTabsObserver.disconnect() 411 | profileTabsObserver.observe(profileDetailsDiv, observerConfig) 412 | } 413 | 414 | const activeIndicatorId = 'plus_active-indicator' 415 | 416 | const createActiveIndicatorElement = (tooltip) => { 417 | const activeIndicator = document.createElement('div') 418 | activeIndicator.id = activeIndicatorId 419 | activeIndicator.className = 'plus-active-indicator rounded-circle bg-success m-1' 420 | 421 | const container = document.createElement('div') 422 | container.className = 'plus-active-indicator-container cursor-pointer plus-tooltip' 423 | container.appendChild(activeIndicator) 424 | container.appendChild(tooltip) 425 | 426 | return container 427 | } 428 | 429 | const createActiveIndicatorTooltip = (innerText) => { 430 | const tooltip = document.createElement('span') 431 | tooltip.className = 'plus-tooltip-text fs-12px' 432 | tooltip.style.width = '180px' 433 | tooltip.style.left = '26px' 434 | tooltip.style.top = '10px' 435 | tooltip.innerText = innerText 436 | return tooltip 437 | } 438 | 439 | const addActiveIndicator = (tooltipText, profileDetailsDiv) => { 440 | const tooltip = createActiveIndicatorTooltip(tooltipText) 441 | const activeIndicator = createActiveIndicatorElement(tooltip) 442 | const avatar = profileDetailsDiv.querySelector('.creator-profile__avatar') 443 | avatar.appendChild(activeIndicator) 444 | } 445 | 446 | const showIndicatorIfActive = (publicKey, profileDetailsDiv) => getTransactionInfo(publicKey, -1, 100) 447 | .then((transactions) => { 448 | const latestTransaction = transactions.reverse() 449 | .find(transaction => transaction['TransactionMetadata']['TransactorPublicKeyBase58Check'] === publicKey) 450 | 451 | if (!latestTransaction) return 452 | 453 | if (document.getElementById(activeIndicatorId)) return 454 | 455 | const blockHashHex = latestTransaction['BlockHashHex'] 456 | if (!blockHashHex || (blockHashHex && blockHashHex.length === 0)) { 457 | // This means the transaction is still in the mempool, which means active within the past 5 min 458 | const tooltipText = `Last active < 5 minutes ago` 459 | addActiveIndicator(tooltipText, profileDetailsDiv) 460 | return 461 | } 462 | 463 | getBlockByHash(blockHashHex).then((res) => { 464 | const header = res['Header'] 465 | const error = res['Error'] 466 | let tooltipText 467 | 468 | // This also means the transaction is still in the mempool 469 | if (error && error.includes('Key not found')) { 470 | tooltipText = `Last active < 5 minutes ago` 471 | } else { 472 | const now = Date.now() 473 | const timestamp = header['TstampSecs'] * 1000 474 | const recentlyActive = now - timestamp < (1000 * 60 * 15) 475 | if (recentlyActive) { 476 | // Block times are ~5 min, so we add that to account for time passed before the transaction was mined 477 | const timeAgo = Math.round((now - timestamp) / 1000 / 60) + 5 478 | tooltipText = `Last active ~${timeAgo} minutes ago` 479 | } 480 | } 481 | 482 | if (tooltipText) { 483 | addActiveIndicator(tooltipText, profileDetailsDiv) 484 | } 485 | }) 486 | }) 487 | 488 | const enrichProfileFromApi = (profileDetailsDiv) => { 489 | const pageUsername = getUsernameFromUrl() 490 | if (!pageUsername) return 491 | 492 | const loggedInPubKey = getLoggedInPublicKey() 493 | if (!loggedInPubKey) return 494 | 495 | const pagePubKey = getPublicKeyFromPage() 496 | if (!pagePubKey) return 497 | 498 | observeProfileDetails(profileDetailsDiv) 499 | 500 | isFollowingPublicKey(pagePubKey, loggedInPubKey).then(followingRes => { 501 | const userDataDiv = getProfileUserDataDiv() 502 | if (!userDataDiv) return Promise.reject() 503 | 504 | addFollowsYouBadgeToProfileHeader(userDataDiv, followingRes['IsFollowing']) 505 | 506 | if (getUsernameFromUrl() !== pageUsername) return Promise.reject() 507 | 508 | }).then(() => getProfileByUsername(pageUsername)).then(pageProfile => { 509 | const userDataDiv = getProfileUserDataDiv() 510 | if (!userDataDiv) return Promise.reject() 511 | 512 | if (getUsernameFromUrl() !== pageUsername) return Promise.reject() 513 | 514 | addNativeCoinPriceToProfileHeader(userDataDiv, pageProfile) 515 | 516 | const circulation = pageProfile['CoinEntry']['CoinsInCirculationNanos'] / deSoInNanos 517 | addHolderEnrichments(circulation) 518 | 519 | const pubKey = pageProfile['PublicKeyBase58Check'] 520 | return Promise.resolve(pubKey) 521 | 522 | }).then(pagePubKey => { 523 | if (!pagePubKey) return Promise.reject() 524 | 525 | return isHoldingPublicKey(pagePubKey, loggedInPubKey).then(res => { 526 | if (getUsernameFromUrl() !== pageUsername) return Promise.reject() 527 | 528 | const userDataDiv = getProfileUserDataDiv() 529 | if (!userDataDiv) return Promise.reject() 530 | 531 | addHodlerBadgeToProfileHeader(userDataDiv, res['IsHodling'], res['BalanceEntry']) 532 | }) 533 | }).then(() => getHodlersByUsername(pageUsername)).then(hodlersList => { 534 | addHoldersCount(hodlersList.length) 535 | 536 | const loggedInUserIsHodler = hodlersList.find(hodler => { 537 | return hodler['HODLerPublicKeyBase58Check'] === loggedInPubKey 538 | }) 539 | if (loggedInUserIsHodler) addSellButton() 540 | 541 | return getUserMetadata(pagePubKey).catch(() => {}) 542 | 543 | }).then(metadata => { 544 | const userDataDiv = getProfileUserDataDiv() 545 | if (!userDataDiv) return Promise.reject() 546 | 547 | if (metadata['JumioVerified']) { 548 | addJumioBadgeToProfileHeader(userDataDiv) 549 | } 550 | }) 551 | .then(() => showIndicatorIfActive(pagePubKey, profileDetailsDiv)) 552 | .catch(() => {}) 553 | } 554 | 555 | const observeProfileInnerContent = (page) => { 556 | const profileDetailsDiv = page.querySelector('creator-profile-details') 557 | if (profileDetailsDiv) { 558 | const observerConfig = { childList: true, subtree: false } 559 | const observer = new MutationObserver(mutations => { 560 | mutations.forEach(mutation => { 561 | Array.from(mutation.addedNodes, node => { 562 | if (node.nodeName !== 'SIMPLE-CENTER-LOADER') enrichProfileFromApi(node) 563 | }) 564 | }) 565 | }) 566 | observer.observe(profileDetailsDiv, observerConfig) 567 | } 568 | } 569 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) Paul Burke 2021 3 | Github: @ipaulpro/bitcloutplus 4 | Distributed under the MIT License (license terms are at http://opensource.org/licenses/MIT). 5 | */ 6 | 7 | const deSoInNanos = 1000000000 8 | 9 | let timer, currentUrl 10 | let identityWindow 11 | 12 | let longPostEnabled = true 13 | let observingHolders = false 14 | 15 | let notificationsJwtMsgId 16 | 17 | const dollarFormatter = new Intl.NumberFormat('en-US', { 18 | style: 'currency', 19 | currency: 'USD' 20 | }) 21 | 22 | const addEditProfileButton = function () { 23 | let editProfileButtonId = 'plus-sidebar-edit-profile' 24 | if (document.getElementById(editProfileButtonId)) return 25 | 26 | const leftBarButtons = document.querySelectorAll('left-bar-button') 27 | try { 28 | leftBarButtons.forEach(button => { 29 | const profileDiv = button.firstElementChild.lastElementChild 30 | const profileAnchor = profileDiv.firstElementChild 31 | 32 | if (profileAnchor.innerText.includes('Profile')) { 33 | const a = document.createElement('a') 34 | a.id = editProfileButtonId 35 | a.href = 'update-profile' 36 | a.className = 'fc-muted fs-12px ml-2 pl-1 pr-1' 37 | a.innerText = 'Edit' 38 | 39 | profileDiv.appendChild(a) 40 | } 41 | }) 42 | } catch (e) {} 43 | } 44 | 45 | const enrichWallet = function (page) { 46 | try { 47 | const holdingsDiv = page.querySelectorAll('.holdings__divider').item(1) 48 | const holdingsValueDiv = holdingsDiv.lastElementChild.children.item(2) 49 | const holdingsCloutValue = parseFloat(holdingsValueDiv.innerText.replace(/[^0-9.]+/g, '')) 50 | 51 | const container = page.querySelector('.container') 52 | const balanceValuesDiv = container.firstElementChild.lastElementChild 53 | const balanceCloutValue = parseFloat(balanceValuesDiv.firstElementChild.innerText.replace(/[^0-9.]+/g, '')) 54 | 55 | const cloutLabelSpan = document.createElement('span') 56 | cloutLabelSpan.className = 'plus-text-muted fs-12px font-weight-normal ml-2' 57 | cloutLabelSpan.innerText = '$DESO' 58 | 59 | const cloutSpan = document.createElement('span') 60 | cloutSpan.className = 'plus-text-muted fs-14px font-weight-normal' 61 | cloutSpan.innerText = `${(holdingsCloutValue + balanceCloutValue).toFixed(4)}` 62 | cloutSpan.appendChild(cloutLabelSpan) 63 | 64 | const totalDiv = document.createElement('div') 65 | totalDiv.className = 'ml-auto mr-15px' 66 | totalDiv.style.lineHeight = '1.2' 67 | totalDiv.appendChild(cloutSpan) 68 | 69 | const topBar = document.getElementsByClassName('global__top-bar').item(0).children.item(1).children.item(1) 70 | topBar.appendChild(totalDiv) 71 | } catch (e) {} 72 | } 73 | 74 | const formatPriceUsd = function (price) { 75 | return `${dollarFormatter.format(price)} USD` 76 | } 77 | 78 | const enrichBalanceBox = function (profile) { 79 | if (!profile) return 80 | 81 | try { 82 | const nativePrice = (profile['CoinPriceDeSoNanos'] / deSoInNanos).toFixed(2) 83 | const spotPrice = getSpotPrice() 84 | const coinPriceUsd = nativePrice * spotPrice 85 | 86 | const creatorCoinBalanceId = 'plus-creator-coin-balance' 87 | const creatorCoinPriceId = 'plus-creator-coin-price' 88 | const creatorCoinPriceUsdId = 'plus-creator-coin-price-usd' 89 | const existingElement = document.getElementById(creatorCoinBalanceId) 90 | if (existingElement) { 91 | document.getElementById(creatorCoinPriceId).innerText = ` ${nativePrice} $DESO ` 92 | document.getElementById(creatorCoinPriceUsdId).innerText = formatPriceUsd(coinPriceUsd) 93 | return 94 | } 95 | 96 | const creatorCoinBalanceContainer = document.createElement('div') 97 | creatorCoinBalanceContainer.id = creatorCoinBalanceId 98 | creatorCoinBalanceContainer.className = 'd-flex justify-content-between pt-10px' 99 | 100 | const coinNameDiv = document.createElement('div') 101 | coinNameDiv.className = 'd-flex' 102 | coinNameDiv.style.textOverflow = 'ellipsis' 103 | coinNameDiv.style.maxWidth = '150px' 104 | coinNameDiv.style.overflow = 'hidden' 105 | coinNameDiv.style.whiteSpace = 'noWrap' 106 | coinNameDiv.innerText = `Your Coin` 107 | 108 | const coinPriceDiv = document.createElement('div') 109 | coinPriceDiv.className = 'd-flex flex-column align-items-end justify-content-end flex-wrap' 110 | 111 | const coinPriceValueDiv = document.createElement('div') 112 | coinPriceValueDiv.id = creatorCoinPriceId 113 | coinPriceValueDiv.innerText = ` ${nativePrice} $DESO ` 114 | 115 | const coinPriceConversionDiv = document.createElement('div') 116 | coinPriceConversionDiv.className = 'd-flex plus-text-muted' 117 | 118 | const coinPriceApproximateDiv = document.createElement('div') 119 | coinPriceApproximateDiv.className = 'ml-10px mr-10px' 120 | coinPriceApproximateDiv.innerText = ' ≈ ' 121 | 122 | const coinPriceUsdDiv = document.createElement('div') 123 | coinPriceUsdDiv.id = creatorCoinPriceUsdId 124 | coinPriceUsdDiv.innerText = formatPriceUsd(coinPriceUsd) 125 | 126 | coinPriceConversionDiv.appendChild(coinPriceApproximateDiv) 127 | coinPriceConversionDiv.appendChild(coinPriceUsdDiv) 128 | coinPriceDiv.appendChild(coinPriceValueDiv) 129 | coinPriceDiv.appendChild(coinPriceConversionDiv) 130 | creatorCoinBalanceContainer.appendChild(coinNameDiv) 131 | creatorCoinBalanceContainer.appendChild(coinPriceDiv) 132 | 133 | const balanceBox = document.getElementsByClassName('right-bar-creators__balance-box').item(0) 134 | balanceBox.appendChild(creatorCoinBalanceContainer) 135 | } catch (e) { } 136 | } 137 | 138 | const checkForNotifications = () => { 139 | const publicKey = getLoggedInPublicKey() 140 | if (!publicKey) return 141 | 142 | const leftBarButtons = document.querySelectorAll('left-bar-button') 143 | if (!leftBarButtons || leftBarButtons.length === 0) return 144 | 145 | const sidebar = leftBarButtons[0].parentElement 146 | 147 | const dividers = sidebar.querySelectorAll('.p-15px') 148 | if (!dividers) return 149 | 150 | const notificationsAnchor = sidebar.querySelector("a[href*='/notifications']") 151 | if (!notificationsAnchor) return 152 | 153 | const menuItem = notificationsAnchor.parentElement.parentElement 154 | if (menuItem.tagName !== 'LEFT-BAR-BUTTON') return 155 | 156 | getUnreadNotificationsCount(publicKey) 157 | .then(data => { 158 | const notificationsCount = data['NotificationsCount'] 159 | const id = 'plus-notifications-count' 160 | if (notificationsCount > 0) { 161 | let countElement 162 | const existingCountElement = document.getElementById(id) 163 | if (existingCountElement) { 164 | countElement = existingCountElement 165 | } else { 166 | countElement = document.createElement('div') 167 | countElement.id = id 168 | countElement.className = 'ml-5px p-5x fs-15px notification' 169 | const div = menuItem.firstElementChild.lastElementChild 170 | div.appendChild(countElement) 171 | } 172 | countElement.innerText = String(notificationsCount) 173 | } else { 174 | const countElement = document.getElementById(id) 175 | if (countElement) countElement.remove() 176 | } 177 | }) 178 | } 179 | 180 | const markNotificationsRead = (jwt) => { 181 | const publicKey = getLoggedInPublicKey() 182 | if (!publicKey) return 183 | 184 | getUnreadNotificationsCount(publicKey) 185 | .then(data => { 186 | const index = data['LastUnreadNotificationIndex'] 187 | const metadata = { 188 | PublicKeyBase58Check: publicKey, 189 | LastSeenIndex: index, 190 | LastUnreadNotificationIndex: index, 191 | UnreadNotifications: 0, 192 | JWT: jwt 193 | } 194 | return setNotificationMetadata(metadata) 195 | }) 196 | .then(() => { 197 | const countElement = document.getElementById('plus-notifications-count') 198 | if (countElement) countElement.remove() 199 | }) 200 | .finally(() => { 201 | notificationsJwtMsgId = null 202 | }) 203 | } 204 | 205 | const extractTransactors = (block) => { 206 | const transactors = new Set() 207 | const transactions = block['Transactions'] 208 | transactions.forEach(transaction => { 209 | const transactor = transaction['TransactionMetadata']['TransactorPublicKeyBase58Check'] 210 | transactors.add(transactor) 211 | }) 212 | return [...transactors] 213 | } 214 | 215 | const getMempoolTransactors = () => new Promise((resolve, reject) => 216 | chrome.runtime.sendMessage({type: 'get-mempool-transactors'}, response => { 217 | if (response.mempoolTransactors) { 218 | resolve(response.mempoolTransactors) 219 | } else { 220 | reject(chrome.runtime.lastError) 221 | } 222 | }) 223 | ) 224 | 225 | const addMempoolTransactors = (transactors) => { 226 | return getMempoolTransactors() 227 | .then(mempoolTransactors => transactors.concat(mempoolTransactors)) 228 | .catch(() => transactors) 229 | } 230 | 231 | const findFollowingInTransactors = (transactors) => { 232 | const publicKey = getLoggedInPublicKey() 233 | return getFollowingByPublicKey(publicKey) 234 | .then(res => { 235 | const followerMap = res['PublicKeyToProfileEntry'] 236 | return Object.values(followerMap) 237 | .filter(follower => transactors.includes(follower['PublicKeyBase58Check'])) 238 | }) 239 | } 240 | 241 | const getOnlineFollowing = () => 242 | getAppState() 243 | .then(appState => appState['BlockHeight']) 244 | .then(getBlockByHeight) 245 | .then(extractTransactors) 246 | .then(addMempoolTransactors) 247 | .then(findFollowingInTransactors) 248 | 249 | const createRecentlyActiveListItem = (user) => { 250 | const username = user['Username'] 251 | 252 | const listItem = document.createElement('a') 253 | listItem.className = 'link--unstyled d-flex align-items-center text-grey5 fs-15px py-2' 254 | listItem.href = `/u/${username}` 255 | 256 | const profilePhotoUrl = getProfilePhotoUrlForPublicKey(user['PublicKeyBase58Check']) 257 | const avatar = document.createElement('div') 258 | avatar.className = 'right-bar-creators-leaderboard__creator-avatar' 259 | avatar.style.backgroundImage = `url("${profilePhotoUrl}")` 260 | 261 | const text = document.createElement('span') 262 | text.innerText = username 263 | 264 | const textContainer = document.createElement('div') 265 | textContainer.className = 'flex-grow-1' 266 | textContainer.appendChild(text) 267 | 268 | const messageIcon = document.createElement('i') 269 | messageIcon.className = 'fas fa-envelope mr-2' 270 | 271 | const messageLink = document.createElement('a') 272 | messageLink.className = 'plus-message-link' 273 | messageLink.href = `/inbox?username=${username}` 274 | messageLink.appendChild(messageIcon) 275 | 276 | listItem.appendChild(avatar) 277 | listItem.appendChild(textContainer) 278 | listItem.appendChild(messageLink) 279 | 280 | return listItem 281 | } 282 | 283 | const addOnlineUsersRightBar = () => { 284 | const boxId = 'plus-online-users' 285 | if (document.getElementById(boxId)) return 286 | 287 | const rightBar = document.querySelector('right-bar-creators') 288 | if (!rightBar) return 289 | 290 | getOnlineFollowing() 291 | .then(onlineUsers => { 292 | if (document.getElementById(boxId)) return 293 | 294 | const listItems = [] 295 | onlineUsers.sort((a, b) => a['Username'].localeCompare(b['Username'])) 296 | .forEach(user => { 297 | const listItem = createRecentlyActiveListItem(user) 298 | listItems.push(listItem) 299 | }) 300 | 301 | const list = document.createElement('div') 302 | list.id = 'plus-online-users-list' 303 | list.className = 'd-flex flex-column overflow-auto' 304 | list.style.maxHeight = '300px' 305 | 306 | if (listItems.length > 0) { 307 | listItems.forEach(listItem => list.appendChild(listItem)) 308 | } else { 309 | const emptyItem = document.createElement('div') 310 | emptyItem.className = 'text-muted' 311 | emptyItem.innerText = '(No users found)' 312 | list.appendChild(emptyItem) 313 | } 314 | 315 | const title = document.createElement('p') 316 | title.className = 'font-weight-bold fs-15px text-white mb-2' 317 | title.innerText = `Recently Active (${onlineUsers.length})` 318 | 319 | const box = document.createElement('div') 320 | box.id = boxId 321 | box.className = 'right-bar-creators__balance-box br-12px p-15px mb-30px fs-13px text-grey5' 322 | box.appendChild(title) 323 | box.append(list) 324 | 325 | const sidebarInner = rightBar.querySelector(':scope > .global__sidebar__inner') 326 | const balanceBox = sidebarInner.querySelector(':scope > .right-bar-creators__balance-box') 327 | const index = Array.from(sidebarInner.children).indexOf(balanceBox) 328 | sidebarInner.insertBefore(box, sidebarInner.children.item(index + 1)) 329 | }) 330 | .catch(console.error) 331 | } 332 | 333 | const addGlobalEnrichments = function () { 334 | addEditProfileButton() 335 | addNewPostButton() 336 | } 337 | 338 | const removeUnfollowLinksInPosts = () => { 339 | const followButtons = document.querySelectorAll('feed-post follow-button') 340 | Array.from(followButtons).forEach(node => { 341 | if (node.innerText === 'Unfollow') node.remove() 342 | }) 343 | } 344 | 345 | // Callback function to execute when body mutations are observed 346 | const appRootObserverCallback = function () { 347 | if (currentUrl !== window.location.href) { 348 | observingHolders = false 349 | currentUrl = window.location.href 350 | } 351 | 352 | addGlobalEnrichments() 353 | removeUnfollowLinksInPosts() 354 | addLogoutButtons() 355 | 356 | const profilePage = document.querySelector('creator-profile-page') 357 | if (profilePage) { 358 | enrichProfile() 359 | return 360 | } 361 | 362 | const nftPostPage = document.querySelector('nft-post-page') 363 | if (nftPostPage) { 364 | enrichNftPostPage(nftPostPage) 365 | return 366 | } 367 | 368 | const postThreadPage = document.querySelector('post-thread-page') 369 | if (postThreadPage) { 370 | showEditPostButtonIfNeeded() 371 | } 372 | } 373 | 374 | const updateUserCreatorCoinPrice = function () { 375 | const key = getLoggedInPublicKey() 376 | getProfileByPublicKey(key).then(profile => { 377 | enrichBalanceBox(profile) 378 | }).catch(() => {}) 379 | } 380 | 381 | const getJwt = (id) => { 382 | const identity = getCurrentIdentity() 383 | if (!identity) return 384 | 385 | const payload = { 386 | accessLevel: identity.accessLevel, 387 | accessLevelHmac: identity.accessLevelHmac, 388 | encryptedSeedHex: identity.encryptedSeedHex 389 | } 390 | 391 | postIdentityMessage(id, 'jwt', payload) 392 | } 393 | 394 | const addLogoutButtons = () => { 395 | const accountSelectorMaskIconId = '__clout-mask-account-selector-icon' 396 | if (document.getElementById(accountSelectorMaskIconId)) return 397 | 398 | if (document.querySelectorAll('.plus-account-logout-icon').length > 0) return 399 | 400 | const listItems = document.querySelectorAll('.change-account-selector_list-item') 401 | listItems.forEach(listItem => { 402 | const avatar = listItem.querySelector(':scope > .change-account-selector__account-image') 403 | 404 | const icon = document.createElement('i') 405 | icon.className = 'plus-account-logout-icon fas fa-times-circle' 406 | 407 | const button = document.createElement('button') 408 | button.className = 'btn btn-link py-0 px-1 text-muted' 409 | button.onclick = () => { 410 | const backgroundImage = avatar.style.backgroundImage 411 | const start = backgroundImage.indexOf('BC1YL') 412 | const end = backgroundImage.indexOf('?', start) 413 | const publicKey = backgroundImage.substring(start, end) 414 | identityWindow = window.open(`https://identity.deso.org/logout?publicKey=${publicKey}`, null, 415 | 'toolbar=no, width=800, height=1000, top=0, left=0') 416 | } 417 | button.appendChild(icon) 418 | 419 | const div = document.createElement('div') 420 | div.className = 'plus-account-logout d-flex flex-row-reverse flex-grow-1' 421 | div.appendChild(button) 422 | 423 | listItem.appendChild(div) 424 | }) 425 | } 426 | 427 | const globalContainerObserverCallback = function () { 428 | updateUserCreatorCoinPrice() 429 | addPostUsernameAutocomplete() 430 | addPostTextAreaListener() 431 | restorePostDraft() 432 | replacePostBtn() 433 | 434 | const notifications = document.querySelector('app-notifications-page') 435 | if (!notifications) { 436 | checkForNotifications() 437 | } else { 438 | notificationsJwtMsgId = uuid() 439 | getJwt(notificationsJwtMsgId) 440 | } 441 | 442 | const profilePage = document.querySelector('creator-profile-page') 443 | if (profilePage) { 444 | observeProfileInnerContent(profilePage) 445 | return 446 | } 447 | 448 | const wallet = document.querySelector('wallet') 449 | if (wallet) { 450 | enrichWallet(wallet) 451 | return 452 | } 453 | 454 | const follows = document.querySelector('manage-follows') 455 | if (follows) { 456 | observeFollowLists(follows) 457 | addBlockedUsersTabToFollows(follows) 458 | 459 | if (isBlockedUsersUrl()) { 460 | addBlockedUsersList(follows) 461 | } 462 | return 463 | } 464 | 465 | if (isTransferNftUrl()) { 466 | createTransferNftPage() 467 | return 468 | } 469 | 470 | const newPost = document.querySelector('app-create-post-page') 471 | if (newPost) { 472 | enrichCreatePostPage(newPost) 473 | checkForEditPostQueryParams(newPost) 474 | } 475 | } 476 | 477 | const bodyObserverCallback = function () { 478 | addOnlineUsersRightBar() 479 | 480 | const modalContainer = document.querySelector('modal-container') 481 | if (modalContainer) { 482 | addPostUsernameAutocomplete() 483 | fixImageLightbox(modalContainer) 484 | } 485 | } 486 | 487 | const onTransactionSigned = (payload) => { 488 | if (!payload) return 489 | 490 | const transactionHex = payload['signedTransactionHex'] 491 | if (!transactionHex) return 492 | 493 | pendingTransactionHex = null 494 | 495 | submitTransaction(transactionHex).then(res => { 496 | const response = res['PostEntryResponse'] 497 | if (response && response['PostHashHex']) { 498 | window.location.href = `posts/${response['PostHashHex']}` 499 | } else { 500 | const metadata = res['Transaction']['TxnMeta'] 501 | const nftPostHash = metadata['NFTPostHash'] 502 | if (nftPostHash) { 503 | if (new URL(window.location).pathname.includes('nft-transfers')) { 504 | window.location.reload() 505 | } else { 506 | const postHashHex = Buffer.from(nftPostHash).toString('hex') 507 | window.location.href = `/nft/${postHashHex}` 508 | } 509 | } else { 510 | window.location.href = window`u/${getLoggedInUsername()}` 511 | } 512 | } 513 | }).catch(() => {}) 514 | } 515 | 516 | const handleLogin = (payload) => { 517 | if (identityWindow) { 518 | identityWindow.close() 519 | identityWindow = null 520 | } 521 | 522 | if (payload['signedTransactionHex']) { 523 | onTransactionSigned(payload) 524 | } else if (payload['users']) { 525 | // After logout 526 | const users = JSON.stringify(payload['users']) 527 | window.localStorage.setItem('identityUsersV2', users) 528 | switchToFirstAccount() 529 | } 530 | } 531 | 532 | const handleSignTransactionResponse = (payload) => { 533 | if (!payload) return 534 | 535 | if (payload['approvalRequired'] && pendingTransactionHex) { 536 | const identityServiceUrl = window.localStorage.getItem('lastIdentityServiceURLV2') 537 | identityWindow = window.open( 538 | `${identityServiceUrl}/approve?tx=${pendingTransactionHex}`, null, 539 | 'toolbar=no, width=800, height=1000, top=0, left=0') 540 | } else if (payload['signedTransactionHex']) { 541 | onTransactionSigned(payload) 542 | } 543 | } 544 | 545 | const handleMessage = (message) => { 546 | const { data: { id: id, method: method, payload: payload } } = message 547 | 548 | if (method === 'login') { 549 | handleLogin(payload) 550 | } else if (payload) { 551 | const jwt = payload['jwt'] 552 | if (jwt) { 553 | if (id === notificationsJwtMsgId) { 554 | markNotificationsRead(jwt) 555 | } else if (id === blockJwtMsgId) { 556 | blockUser(jwt) 557 | } 558 | } 559 | else if (id === pendingIdentityMessageId && payload) { 560 | if (payload['encryptedMessage']) { 561 | const encryptedMessage = payload['encryptedMessage'] 562 | if (encryptedMessage) onNftTransferUnlockableEncrypted(encryptedMessage) 563 | } else if (payload['decryptedHexes']) { 564 | const unlockableText = Object.values(payload['decryptedHexes'])[0] 565 | if (unlockableText) onNftTransferUnlockableDecrypted(unlockableText) 566 | } else { 567 | handleSignTransactionResponse(payload) 568 | } 569 | pendingIdentityMessageId = null 570 | } 571 | } 572 | } 573 | 574 | const init = function () { 575 | window.addEventListener('message', handleMessage) 576 | 577 | chrome.storage.local.get(['longPost'], items => { 578 | if (items.longPost === undefined) { 579 | chrome.storage.local.set({ longPost: true }).catch() 580 | } else { 581 | longPostEnabled = items.longPost 582 | } 583 | }) 584 | 585 | // app-root is dynamically loaded, so we observe changes to the child list 586 | const appRoot = document.querySelector('app-root') 587 | if (appRoot) { 588 | const appRootObserverConfig = { childList: true, subtree: true } 589 | const appRootObserver = new MutationObserver(appRootObserverCallback) 590 | appRootObserver.observe(appRoot, appRootObserverConfig) 591 | } 592 | 593 | const globalContainer = document.getElementsByClassName('global__container')[0] 594 | if (globalContainer) { 595 | const globalObserverConfig = { childList: true, subtree: false } 596 | const globalObserver = new MutationObserver(globalContainerObserverCallback) 597 | globalObserver.observe(globalContainer, globalObserverConfig) 598 | } 599 | 600 | const body = document.getElementsByTagName('body')[0] 601 | if (body) { 602 | const bodyObserverConfig = { childList: true, subtree: false } 603 | const bodyObserver = new MutationObserver(bodyObserverCallback) 604 | bodyObserver.observe(body, bodyObserverConfig) 605 | } 606 | 607 | if (timer) clearInterval(timer) 608 | timer = setInterval(updateUserCreatorCoinPrice, 5 * 60 * 1000) 609 | } 610 | 611 | init() 612 | -------------------------------------------------------------------------------- /vendor/buffer/buffer.min.js: -------------------------------------------------------------------------------- 1 | !function(t){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).Buffer=t()}}((function(){return function t(r,e,n){function i(f,u){if(!e[f]){if(!r[f]){var s="function"==typeof require&&require;if(!u&&s)return s(f,!0);if(o)return o(f,!0);var h=new Error("Cannot find module '"+f+"'");throw h.code="MODULE_NOT_FOUND",h}var a=e[f]={exports:{}};r[f][0].call(a.exports,(function(t){return i(r[f][1][t]||t)}),a,a.exports,t,r,e,n)}return e[f].exports}for(var o="function"==typeof require&&require,f=0;ft.from(r).toString("hex")}).call(this)}).call(this,t("buffer").Buffer)},{buffer:3}],2:[function(t,r,e){"use strict";e.byteLength=function(t){var r=h(t),e=r[0],n=r[1];return 3*(e+n)/4-n},e.toByteArray=function(t){var r,e,n=h(t),f=n[0],u=n[1],s=new o(function(t,r,e){return 3*(r+e)/4-e}(0,f,u)),a=0,c=u>0?f-4:f;for(e=0;e>16&255,s[a++]=r>>8&255,s[a++]=255&r;2===u&&(r=i[t.charCodeAt(e)]<<2|i[t.charCodeAt(e+1)]>>4,s[a++]=255&r);1===u&&(r=i[t.charCodeAt(e)]<<10|i[t.charCodeAt(e+1)]<<4|i[t.charCodeAt(e+2)]>>2,s[a++]=r>>8&255,s[a++]=255&r);return s},e.fromByteArray=function(t){for(var r,e=t.length,i=e%3,o=[],f=16383,u=0,s=e-i;us?s:u+f));1===i?(r=t[e-1],o.push(n[r>>2]+n[r<<4&63]+"==")):2===i&&(r=(t[e-2]<<8)+t[e-1],o.push(n[r>>10]+n[r>>4&63]+n[r<<2&63]+"="));return o.join("")};for(var n=[],i=[],o="undefined"!=typeof Uint8Array?Uint8Array:Array,f="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",u=0,s=f.length;u0)throw new Error("Invalid string. Length must be a multiple of 4");var e=t.indexOf("=");return-1===e&&(e=r),[e,e===r?0:4-e%4]}function a(t,r,e){for(var i,o,f=[],u=r;u>18&63]+n[o>>12&63]+n[o>>6&63]+n[63&o]);return f.join("")}i["-".charCodeAt(0)]=62,i["_".charCodeAt(0)]=63},{}],3:[function(t,r,e){(function(r){(function(){ 2 | /*! 3 | * The buffer module from node.js, for the browser. 4 | * 5 | * @author Feross Aboukhadijeh 6 | * @license MIT 7 | */ 8 | "use strict";var r=t("base64-js"),n=t("ieee754");e.Buffer=f,e.SlowBuffer=function(t){+t!=t&&(t=0);return f.alloc(+t)},e.INSPECT_MAX_BYTES=50;var i=2147483647;function o(t){if(t>i)throw new RangeError('The value "'+t+'" is invalid for option "size"');var r=new Uint8Array(t);return r.__proto__=f.prototype,r}function f(t,r,e){if("number"==typeof t){if("string"==typeof r)throw new TypeError('The "string" argument must be of type string. Received type number');return h(t)}return u(t,r,e)}function u(t,r,e){if("string"==typeof t)return function(t,r){"string"==typeof r&&""!==r||(r="utf8");if(!f.isEncoding(r))throw new TypeError("Unknown encoding: "+r);var e=0|p(t,r),n=o(e),i=n.write(t,r);i!==e&&(n=n.slice(0,i));return n}(t,r);if(ArrayBuffer.isView(t))return a(t);if(null==t)throw TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof t);if(D(t,ArrayBuffer)||t&&D(t.buffer,ArrayBuffer))return function(t,r,e){if(r<0||t.byteLength=i)throw new RangeError("Attempt to allocate Buffer larger than maximum size: 0x"+i.toString(16)+" bytes");return 0|t}function p(t,r){if(f.isBuffer(t))return t.length;if(ArrayBuffer.isView(t)||D(t,ArrayBuffer))return t.byteLength;if("string"!=typeof t)throw new TypeError('The "string" argument must be one of type string, Buffer, or ArrayBuffer. Received type '+typeof t);var e=t.length,n=arguments.length>2&&!0===arguments[2];if(!n&&0===e)return 0;for(var i=!1;;)switch(r){case"ascii":case"latin1":case"binary":return e;case"utf8":case"utf-8":return P(t).length;case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return 2*e;case"hex":return e>>>1;case"base64":return j(t).length;default:if(i)return n?-1:P(t).length;r=(""+r).toLowerCase(),i=!0}}function l(t,r,e){var n=!1;if((void 0===r||r<0)&&(r=0),r>this.length)return"";if((void 0===e||e>this.length)&&(e=this.length),e<=0)return"";if((e>>>=0)<=(r>>>=0))return"";for(t||(t="utf8");;)switch(t){case"hex":return S(this,r,e);case"utf8":case"utf-8":return U(this,r,e);case"ascii":return T(this,r,e);case"latin1":case"binary":return I(this,r,e);case"base64":return B(this,r,e);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return C(this,r,e);default:if(n)throw new TypeError("Unknown encoding: "+t);t=(t+"").toLowerCase(),n=!0}}function y(t,r,e){var n=t[r];t[r]=t[e],t[e]=n}function g(t,r,e,n,i){if(0===t.length)return-1;if("string"==typeof e?(n=e,e=0):e>2147483647?e=2147483647:e<-2147483648&&(e=-2147483648),q(e=+e)&&(e=i?0:t.length-1),e<0&&(e=t.length+e),e>=t.length){if(i)return-1;e=t.length-1}else if(e<0){if(!i)return-1;e=0}if("string"==typeof r&&(r=f.from(r,n)),f.isBuffer(r))return 0===r.length?-1:w(t,r,e,n,i);if("number"==typeof r)return r&=255,"function"==typeof Uint8Array.prototype.indexOf?i?Uint8Array.prototype.indexOf.call(t,r,e):Uint8Array.prototype.lastIndexOf.call(t,r,e):w(t,[r],e,n,i);throw new TypeError("val must be string, number or Buffer")}function w(t,r,e,n,i){var o,f=1,u=t.length,s=r.length;if(void 0!==n&&("ucs2"===(n=String(n).toLowerCase())||"ucs-2"===n||"utf16le"===n||"utf-16le"===n)){if(t.length<2||r.length<2)return-1;f=2,u/=2,s/=2,e/=2}function h(t,r){return 1===f?t[r]:t.readUInt16BE(r*f)}if(i){var a=-1;for(o=e;ou&&(e=u-s),o=e;o>=0;o--){for(var c=!0,p=0;pi&&(n=i):n=i;var o=r.length;n>o/2&&(n=o/2);for(var f=0;f>8,i=e%256,o.push(i),o.push(n);return o}(r,t.length-e),t,e,n)}function B(t,e,n){return 0===e&&n===t.length?r.fromByteArray(t):r.fromByteArray(t.slice(e,n))}function U(t,r,e){e=Math.min(t.length,e);for(var n=[],i=r;i239?4:h>223?3:h>191?2:1;if(i+c<=e)switch(c){case 1:h<128&&(a=h);break;case 2:128==(192&(o=t[i+1]))&&(s=(31&h)<<6|63&o)>127&&(a=s);break;case 3:o=t[i+1],f=t[i+2],128==(192&o)&&128==(192&f)&&(s=(15&h)<<12|(63&o)<<6|63&f)>2047&&(s<55296||s>57343)&&(a=s);break;case 4:o=t[i+1],f=t[i+2],u=t[i+3],128==(192&o)&&128==(192&f)&&128==(192&u)&&(s=(15&h)<<18|(63&o)<<12|(63&f)<<6|63&u)>65535&&s<1114112&&(a=s)}null===a?(a=65533,c=1):a>65535&&(a-=65536,n.push(a>>>10&1023|55296),a=56320|1023&a),n.push(a),i+=c}return function(t){var r=t.length;if(r<=_)return String.fromCharCode.apply(String,t);var e="",n=0;for(;nr&&(t+=" ... "),""},f.prototype.compare=function(t,r,e,n,i){if(D(t,Uint8Array)&&(t=f.from(t,t.offset,t.byteLength)),!f.isBuffer(t))throw new TypeError('The "target" argument must be one of type Buffer or Uint8Array. Received type '+typeof t);if(void 0===r&&(r=0),void 0===e&&(e=t?t.length:0),void 0===n&&(n=0),void 0===i&&(i=this.length),r<0||e>t.length||n<0||i>this.length)throw new RangeError("out of range index");if(n>=i&&r>=e)return 0;if(n>=i)return-1;if(r>=e)return 1;if(this===t)return 0;for(var o=(i>>>=0)-(n>>>=0),u=(e>>>=0)-(r>>>=0),s=Math.min(o,u),h=this.slice(n,i),a=t.slice(r,e),c=0;c>>=0,isFinite(e)?(e>>>=0,void 0===n&&(n="utf8")):(n=e,e=void 0)}var i=this.length-r;if((void 0===e||e>i)&&(e=i),t.length>0&&(e<0||r<0)||r>this.length)throw new RangeError("Attempt to write outside buffer bounds");n||(n="utf8");for(var o=!1;;)switch(n){case"hex":return d(this,t,r,e);case"utf8":case"utf-8":return v(this,t,r,e);case"ascii":return b(this,t,r,e);case"latin1":case"binary":return m(this,t,r,e);case"base64":return E(this,t,r,e);case"ucs2":case"ucs-2":case"utf16le":case"utf-16le":return A(this,t,r,e);default:if(o)throw new TypeError("Unknown encoding: "+n);n=(""+n).toLowerCase(),o=!0}},f.prototype.toJSON=function(){return{type:"Buffer",data:Array.prototype.slice.call(this._arr||this,0)}};var _=4096;function T(t,r,e){var n="";e=Math.min(t.length,e);for(var i=r;in)&&(e=n);for(var i="",o=r;oe)throw new RangeError("Trying to access beyond buffer length")}function R(t,r,e,n,i,o){if(!f.isBuffer(t))throw new TypeError('"buffer" argument must be a Buffer instance');if(r>i||rt.length)throw new RangeError("Index out of range")}function x(t,r,e,n,i,o){if(e+n>t.length)throw new RangeError("Index out of range");if(e<0)throw new RangeError("Index out of range")}function M(t,r,e,i,o){return r=+r,e>>>=0,o||x(t,0,e,4),n.write(t,r,e,i,23,4),e+4}function O(t,r,e,i,o){return r=+r,e>>>=0,o||x(t,0,e,8),n.write(t,r,e,i,52,8),e+8}f.prototype.slice=function(t,r){var e=this.length;(t=~~t)<0?(t+=e)<0&&(t=0):t>e&&(t=e),(r=void 0===r?e:~~r)<0?(r+=e)<0&&(r=0):r>e&&(r=e),r>>=0,r>>>=0,e||L(t,r,this.length);for(var n=this[t],i=1,o=0;++o>>=0,r>>>=0,e||L(t,r,this.length);for(var n=this[t+--r],i=1;r>0&&(i*=256);)n+=this[t+--r]*i;return n},f.prototype.readUInt8=function(t,r){return t>>>=0,r||L(t,1,this.length),this[t]},f.prototype.readUInt16LE=function(t,r){return t>>>=0,r||L(t,2,this.length),this[t]|this[t+1]<<8},f.prototype.readUInt16BE=function(t,r){return t>>>=0,r||L(t,2,this.length),this[t]<<8|this[t+1]},f.prototype.readUInt32LE=function(t,r){return t>>>=0,r||L(t,4,this.length),(this[t]|this[t+1]<<8|this[t+2]<<16)+16777216*this[t+3]},f.prototype.readUInt32BE=function(t,r){return t>>>=0,r||L(t,4,this.length),16777216*this[t]+(this[t+1]<<16|this[t+2]<<8|this[t+3])},f.prototype.readIntLE=function(t,r,e){t>>>=0,r>>>=0,e||L(t,r,this.length);for(var n=this[t],i=1,o=0;++o=(i*=128)&&(n-=Math.pow(2,8*r)),n},f.prototype.readIntBE=function(t,r,e){t>>>=0,r>>>=0,e||L(t,r,this.length);for(var n=r,i=1,o=this[t+--n];n>0&&(i*=256);)o+=this[t+--n]*i;return o>=(i*=128)&&(o-=Math.pow(2,8*r)),o},f.prototype.readInt8=function(t,r){return t>>>=0,r||L(t,1,this.length),128&this[t]?-1*(255-this[t]+1):this[t]},f.prototype.readInt16LE=function(t,r){t>>>=0,r||L(t,2,this.length);var e=this[t]|this[t+1]<<8;return 32768&e?4294901760|e:e},f.prototype.readInt16BE=function(t,r){t>>>=0,r||L(t,2,this.length);var e=this[t+1]|this[t]<<8;return 32768&e?4294901760|e:e},f.prototype.readInt32LE=function(t,r){return t>>>=0,r||L(t,4,this.length),this[t]|this[t+1]<<8|this[t+2]<<16|this[t+3]<<24},f.prototype.readInt32BE=function(t,r){return t>>>=0,r||L(t,4,this.length),this[t]<<24|this[t+1]<<16|this[t+2]<<8|this[t+3]},f.prototype.readFloatLE=function(t,r){return t>>>=0,r||L(t,4,this.length),n.read(this,t,!0,23,4)},f.prototype.readFloatBE=function(t,r){return t>>>=0,r||L(t,4,this.length),n.read(this,t,!1,23,4)},f.prototype.readDoubleLE=function(t,r){return t>>>=0,r||L(t,8,this.length),n.read(this,t,!0,52,8)},f.prototype.readDoubleBE=function(t,r){return t>>>=0,r||L(t,8,this.length),n.read(this,t,!1,52,8)},f.prototype.writeUIntLE=function(t,r,e,n){(t=+t,r>>>=0,e>>>=0,n)||R(this,t,r,e,Math.pow(2,8*e)-1,0);var i=1,o=0;for(this[r]=255&t;++o>>=0,e>>>=0,n)||R(this,t,r,e,Math.pow(2,8*e)-1,0);var i=e-1,o=1;for(this[r+i]=255&t;--i>=0&&(o*=256);)this[r+i]=t/o&255;return r+e},f.prototype.writeUInt8=function(t,r,e){return t=+t,r>>>=0,e||R(this,t,r,1,255,0),this[r]=255&t,r+1},f.prototype.writeUInt16LE=function(t,r,e){return t=+t,r>>>=0,e||R(this,t,r,2,65535,0),this[r]=255&t,this[r+1]=t>>>8,r+2},f.prototype.writeUInt16BE=function(t,r,e){return t=+t,r>>>=0,e||R(this,t,r,2,65535,0),this[r]=t>>>8,this[r+1]=255&t,r+2},f.prototype.writeUInt32LE=function(t,r,e){return t=+t,r>>>=0,e||R(this,t,r,4,4294967295,0),this[r+3]=t>>>24,this[r+2]=t>>>16,this[r+1]=t>>>8,this[r]=255&t,r+4},f.prototype.writeUInt32BE=function(t,r,e){return t=+t,r>>>=0,e||R(this,t,r,4,4294967295,0),this[r]=t>>>24,this[r+1]=t>>>16,this[r+2]=t>>>8,this[r+3]=255&t,r+4},f.prototype.writeIntLE=function(t,r,e,n){if(t=+t,r>>>=0,!n){var i=Math.pow(2,8*e-1);R(this,t,r,e,i-1,-i)}var o=0,f=1,u=0;for(this[r]=255&t;++o>0)-u&255;return r+e},f.prototype.writeIntBE=function(t,r,e,n){if(t=+t,r>>>=0,!n){var i=Math.pow(2,8*e-1);R(this,t,r,e,i-1,-i)}var o=e-1,f=1,u=0;for(this[r+o]=255&t;--o>=0&&(f*=256);)t<0&&0===u&&0!==this[r+o+1]&&(u=1),this[r+o]=(t/f>>0)-u&255;return r+e},f.prototype.writeInt8=function(t,r,e){return t=+t,r>>>=0,e||R(this,t,r,1,127,-128),t<0&&(t=255+t+1),this[r]=255&t,r+1},f.prototype.writeInt16LE=function(t,r,e){return t=+t,r>>>=0,e||R(this,t,r,2,32767,-32768),this[r]=255&t,this[r+1]=t>>>8,r+2},f.prototype.writeInt16BE=function(t,r,e){return t=+t,r>>>=0,e||R(this,t,r,2,32767,-32768),this[r]=t>>>8,this[r+1]=255&t,r+2},f.prototype.writeInt32LE=function(t,r,e){return t=+t,r>>>=0,e||R(this,t,r,4,2147483647,-2147483648),this[r]=255&t,this[r+1]=t>>>8,this[r+2]=t>>>16,this[r+3]=t>>>24,r+4},f.prototype.writeInt32BE=function(t,r,e){return t=+t,r>>>=0,e||R(this,t,r,4,2147483647,-2147483648),t<0&&(t=4294967295+t+1),this[r]=t>>>24,this[r+1]=t>>>16,this[r+2]=t>>>8,this[r+3]=255&t,r+4},f.prototype.writeFloatLE=function(t,r,e){return M(this,t,r,!0,e)},f.prototype.writeFloatBE=function(t,r,e){return M(this,t,r,!1,e)},f.prototype.writeDoubleLE=function(t,r,e){return O(this,t,r,!0,e)},f.prototype.writeDoubleBE=function(t,r,e){return O(this,t,r,!1,e)},f.prototype.copy=function(t,r,e,n){if(!f.isBuffer(t))throw new TypeError("argument should be a Buffer");if(e||(e=0),n||0===n||(n=this.length),r>=t.length&&(r=t.length),r||(r=0),n>0&&n=this.length)throw new RangeError("Index out of range");if(n<0)throw new RangeError("sourceEnd out of bounds");n>this.length&&(n=this.length),t.length-r=0;--o)t[o+r]=this[o+e];else Uint8Array.prototype.set.call(t,this.subarray(e,n),r);return i},f.prototype.fill=function(t,r,e,n){if("string"==typeof t){if("string"==typeof r?(n=r,r=0,e=this.length):"string"==typeof e&&(n=e,e=this.length),void 0!==n&&"string"!=typeof n)throw new TypeError("encoding must be a string");if("string"==typeof n&&!f.isEncoding(n))throw new TypeError("Unknown encoding: "+n);if(1===t.length){var i=t.charCodeAt(0);("utf8"===n&&i<128||"latin1"===n)&&(t=i)}}else"number"==typeof t&&(t&=255);if(r<0||this.length>>=0,e=void 0===e?this.length:e>>>0,t||(t=0),"number"==typeof t)for(o=r;o55295&&e<57344){if(!i){if(e>56319){(r-=3)>-1&&o.push(239,191,189);continue}if(f+1===n){(r-=3)>-1&&o.push(239,191,189);continue}i=e;continue}if(e<56320){(r-=3)>-1&&o.push(239,191,189),i=e;continue}e=65536+(i-55296<<10|e-56320)}else i&&(r-=3)>-1&&o.push(239,191,189);if(i=null,e<128){if((r-=1)<0)break;o.push(e)}else if(e<2048){if((r-=2)<0)break;o.push(e>>6|192,63&e|128)}else if(e<65536){if((r-=3)<0)break;o.push(e>>12|224,e>>6&63|128,63&e|128)}else{if(!(e<1114112))throw new Error("Invalid code point");if((r-=4)<0)break;o.push(e>>18|240,e>>12&63|128,e>>6&63|128,63&e|128)}}return o}function j(t){return r.toByteArray(function(t){if((t=(t=t.split("=")[0]).trim().replace(k,"")).length<2)return"";for(;t.length%4!=0;)t+="=";return t}(t))}function z(t,r,e,n){for(var i=0;i=r.length||i>=t.length);++i)r[i+e]=t[i];return i}function D(t,r){return t instanceof r||null!=t&&null!=t.constructor&&null!=t.constructor.name&&t.constructor.name===r.name}function q(t){return t!=t}}).call(this)}).call(this,t("buffer").Buffer)},{"base64-js":2,buffer:3,ieee754:4}],4:[function(t,r,e){ 9 | /*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh */ 10 | e.read=function(t,r,e,n,i){var o,f,u=8*i-n-1,s=(1<>1,a=-7,c=e?i-1:0,p=e?-1:1,l=t[r+c];for(c+=p,o=l&(1<<-a)-1,l>>=-a,a+=u;a>0;o=256*o+t[r+c],c+=p,a-=8);for(f=o&(1<<-a)-1,o>>=-a,a+=n;a>0;f=256*f+t[r+c],c+=p,a-=8);if(0===o)o=1-h;else{if(o===s)return f?NaN:1/0*(l?-1:1);f+=Math.pow(2,n),o-=h}return(l?-1:1)*f*Math.pow(2,o-n)},e.write=function(t,r,e,n,i,o){var f,u,s,h=8*o-i-1,a=(1<>1,p=23===i?Math.pow(2,-24)-Math.pow(2,-77):0,l=n?0:o-1,y=n?1:-1,g=r<0||0===r&&1/r<0?1:0;for(r=Math.abs(r),isNaN(r)||r===1/0?(u=isNaN(r)?1:0,f=a):(f=Math.floor(Math.log(r)/Math.LN2),r*(s=Math.pow(2,-f))<1&&(f--,s*=2),(r+=f+c>=1?p/s:p*Math.pow(2,1-c))*s>=2&&(f++,s/=2),f+c>=a?(u=0,f=a):f+c>=1?(u=(r*s-1)*Math.pow(2,i),f+=c):(u=r*Math.pow(2,c-1)*Math.pow(2,i),f=0));i>=8;t[e+l]=255&u,l+=y,u/=256,i-=8);for(f=f<0;t[e+l]=255&f,l+=y,f/=256,h-=8);t[e+l-y]|=128*g}},{}]},{},[1])(1)})); 11 | -------------------------------------------------------------------------------- /lib/nft.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (C) Paul Burke 2021 3 | Github: @ipaulpro/bitcloutplus 4 | Distributed under the MIT License (license terms are at http://opensource.org/licenses/MIT). 5 | */ 6 | 7 | const DESO_PUBLIC_KEY_PREFIX = 'BC1YL' 8 | 9 | const transferNftButtonId = 'plus-nft-transfer-button' 10 | 11 | let isRequestingNftEntries = false, 12 | transferNftUnlockableText = null, 13 | transferNftPostEntry = null, 14 | transferNftOwnedEntries = [] 15 | 16 | const isTransferNftUrl = () => { 17 | const segments = new URL(document.location).pathname.split('/') 18 | return segments[1] === 'nft' && segments[segments.length - 1] === 'transfer' 19 | } 20 | 21 | const getNftPostPageFooter = (nftPostPage) => { 22 | const nftPost = nftPostPage.querySelector('nft-post') 23 | 24 | const feedPostElement = nftPost.querySelector('feed-post') 25 | if (!feedPostElement) return 26 | 27 | return feedPostElement.firstElementChild.lastElementChild 28 | } 29 | 30 | const enrichNftPostPage = (nftPostPage) => { 31 | if (!nftPostPage || isRequestingNftEntries) return 32 | 33 | if (document.getElementById(transferNftButtonId)) return 34 | 35 | const publicKey = getLoggedInPublicKey() 36 | const postHashHex = getPostHashHexFromUrl() 37 | if (!publicKey || !postHashHex) return 38 | 39 | const footerElement = getNftPostPageFooter(nftPostPage) 40 | if (!footerElement) return 41 | 42 | isRequestingNftEntries = true 43 | 44 | addNftExtraDataToNftPage(nftPostPage) 45 | 46 | getNftEntriesForPostHashHex(publicKey, postHashHex) 47 | .then(nftEntries => { 48 | if (document.getElementById(transferNftButtonId)) return 49 | 50 | const ownedEntries = nftEntries.filter(entry => entry['OwnerPublicKeyBase58Check'] === publicKey) 51 | if (ownedEntries.length === 0 || !isRequestingNftEntries) return 52 | 53 | const container = footerElement.firstElementChild 54 | container.firstElementChild.classList.add('flex-grow-1') 55 | 56 | const transferButton = createTransferNftButton(postHashHex) 57 | container.appendChild(transferButton) 58 | 59 | const ownedEntriesForSale = ownedEntries.filter(entry => entry['IsForSale'] === true) 60 | if (ownedEntriesForSale.length === ownedEntries.length) { 61 | transferButton.disabled = true 62 | transferButton.title = 'You cannot transfer an NFT that is for sale' 63 | } 64 | }) 65 | .finally(() => { 66 | isRequestingNftEntries = false 67 | }) 68 | } 69 | 70 | const signTransaction = (res) => { 71 | const transactionHex = res['TransactionHex'] 72 | if (!transactionHex) { 73 | return new Error('Error creating transaction') 74 | } 75 | 76 | const identity = getCurrentIdentity() 77 | if (!identity) { 78 | return new Error('No Identity found') 79 | } 80 | 81 | const id = uuid() 82 | sendSignTransactionMsg(identity, transactionHex, id) 83 | } 84 | 85 | const onSerialNumberSelected = () => { 86 | const serialNumber = getSerialNumber() 87 | 88 | transferNftUnlockableText = null 89 | 90 | const nftEntry = transferNftOwnedEntries.find(entry => entry['SerialNumber'] === serialNumber) 91 | if (!nftEntry) return 92 | 93 | const encryptedUnlockableText = nftEntry['EncryptedUnlockableText'] 94 | const lastOwnerPublicKey = nftEntry['LastOwnerPublicKeyBase58Check'] 95 | 96 | if (encryptedUnlockableText) { 97 | sendDecryptMsg(encryptedUnlockableText, lastOwnerPublicKey) 98 | } 99 | 100 | const hasUnlockable = transferNftPostEntry['HasUnlockable'] 101 | if (hasUnlockable) { 102 | const unlockableTextDiv = document.getElementById('plus-nft-transfer-unlockable-text') 103 | unlockableTextDiv.style.display = 'inherit' 104 | unlockableTextDiv.onclick = () => showUnlockableTextDialog().then((res) => { 105 | if (res.isConfirmed) { 106 | transferNftUnlockableText = res.value 107 | } 108 | }) 109 | } 110 | } 111 | 112 | const createSerialNumberSelector = () => { 113 | if (transferNftOwnedEntries.length === 0) { 114 | throw new Error('User doesn\'t own any entries for this NFT.') 115 | } 116 | 117 | const serialNumberSelector = document.createElement('select') 118 | serialNumberSelector.id = 'plus_nft-serial-number-selector' 119 | serialNumberSelector.className = 'form-control w-auto' 120 | serialNumberSelector.addEventListener('change', onSerialNumberSelected) 121 | 122 | const options = [] 123 | transferNftOwnedEntries.forEach(entry => { 124 | const option = document.createElement('option') 125 | option.value = entry['SerialNumber'] 126 | option.innerText = `Serial #${entry['SerialNumber']}` 127 | options.push(option) 128 | }) 129 | options.forEach(option => serialNumberSelector.appendChild(option)) 130 | 131 | const unlockableTextIcon = document.createElement('i') 132 | unlockableTextIcon.className = 'fas fa-unlock-alt' 133 | 134 | const unlockableTextSpan = document.createElement('span') 135 | unlockableTextSpan.innerText = 'Unlockable text' 136 | unlockableTextSpan.className = 'ml-2' 137 | 138 | const encryptedUnlockableTextDiv = document.createElement('div') 139 | encryptedUnlockableTextDiv.id = 'plus-nft-transfer-unlockable-text' 140 | encryptedUnlockableTextDiv.className = 'ml-3 cursor-pointer align-items-center' 141 | encryptedUnlockableTextDiv.style.display = 'none' 142 | encryptedUnlockableTextDiv.appendChild(unlockableTextIcon) 143 | encryptedUnlockableTextDiv.appendChild(unlockableTextSpan) 144 | 145 | const container = document.createElement('div') 146 | container.className = 'flex-grow-1 d-flex align-items-center' 147 | container.appendChild(serialNumberSelector) 148 | container.appendChild(encryptedUnlockableTextDiv) 149 | 150 | return container 151 | } 152 | 153 | const createNftPostElement = (postEntry, notFoundElement, username, buttonElements, serialNumberElement, singleEntryDisplayed = false) => { 154 | const postDiv = document.createElement('div') 155 | postDiv.id = `plus-nft-post-${postEntry['PostHashHex']}` 156 | postDiv.className = 'feed-post__container js-feed-post-hover border d-flex justify-content-left w-100 px-15px pb-15px pt-15px feed-post__parent-post-font-size cursor-pointer' 157 | postDiv.onclick = () => window.location.href = `/nft/${postEntry['PostHashHex']}` 158 | if (singleEntryDisplayed) postDiv.classList.add('feed-post__blue-border') 159 | 160 | const avatarAnchor = document.createElement('a') 161 | avatarAnchor.className = 'feed-post__avatar br-12px' 162 | avatarAnchor.style.backgroundImage = `url("${getProfilePhotoUrlForPublicKey(postEntry['PosterPublicKeyBase58Check'])}")` 163 | 164 | const avatarContainer = document.createElement('div') 165 | avatarContainer.className = 'feed-post__avatar-container' 166 | avatarContainer.appendChild(avatarAnchor) 167 | postDiv.appendChild(avatarContainer) 168 | 169 | const contentInnerDiv = document.createElement('div') 170 | contentInnerDiv.className = 'roboto-regular mt-1' 171 | contentInnerDiv.style.overflowWrap = 'anywhere' 172 | contentInnerDiv.style.wordBreak = 'break-word' 173 | contentInnerDiv.style.outline = 'none' 174 | contentInnerDiv.innerText = postEntry['Body'] 175 | 176 | const imageUrls = postEntry['ImageURLs'] 177 | if (imageUrls && imageUrls.length > 0) { 178 | const contentImage = document.createElement('img') 179 | contentImage.className = 'feed-post__image' 180 | contentImage.src = imageUrls[0] 181 | 182 | const contentImageDiv = document.createElement('div') 183 | contentImageDiv.className = 'feed-post__image-container' 184 | contentImageDiv.appendChild(contentImage) 185 | contentInnerDiv.appendChild(contentImageDiv) 186 | } 187 | 188 | const usernameDiv = document.createElement('div') 189 | usernameDiv.className = 'fc-default font-weight-bold' 190 | usernameDiv.innerText = username 191 | 192 | const contentOuterDiv = document.createElement('div') 193 | contentOuterDiv.className = 'w-100' 194 | contentOuterDiv.appendChild(usernameDiv) 195 | contentOuterDiv.appendChild(contentInnerDiv) 196 | postDiv.appendChild(contentOuterDiv) 197 | 198 | const postFooterContentDiv = document.createElement('div') 199 | postFooterContentDiv.className = 'd-flex justify-content-between align-items-center' 200 | postFooterContentDiv.appendChild(serialNumberElement) 201 | 202 | if (buttonElements) { 203 | buttonElements.forEach(button => { 204 | postFooterContentDiv.appendChild(button) 205 | }) 206 | } 207 | 208 | const postFooterDiv = document.createElement('div') 209 | postFooterDiv.className = 'p-15px fs-15px w-100 background-color-grey' 210 | if (singleEntryDisplayed) postFooterDiv.classList.add('feed-post__blue-border') 211 | postFooterDiv.appendChild(postFooterContentDiv) 212 | notFoundElement.appendChild(postDiv) 213 | notFoundElement.appendChild(postFooterDiv) 214 | 215 | const padding = document.createElement('div') 216 | padding.className = 'w-100' 217 | padding.classList.add(singleEntryDisplayed ? 'p-35px' : 'p-1') 218 | 219 | const postContainerDiv = document.createElement('div') 220 | postContainerDiv.className = 'feed-post__container w-100 px-15px' 221 | if (!singleEntryDisplayed) postContainerDiv.classList.add('pt-15px') 222 | postContainerDiv.appendChild(postDiv) 223 | postContainerDiv.appendChild(postFooterDiv) 224 | postContainerDiv.appendChild(padding) 225 | 226 | notFoundElement.appendChild(postContainerDiv) 227 | } 228 | 229 | const setCustomPageTopBarTitle = (title) => { 230 | const topBar = document.querySelector(`top-bar-mobile-navigation-control`) 231 | if (!topBar) return 232 | 233 | const titleElement = topBar.parentElement 234 | titleElement.innerText = title 235 | } 236 | 237 | const getCustomPageNotFoundElement = () => { 238 | const notFoundElement = document.querySelector(`not-found`) 239 | const notFoundContentContainer = document.querySelector(`.not-found__content-container`) 240 | notFoundElement.removeChild(notFoundContentContainer) 241 | return notFoundElement 242 | } 243 | 244 | function showSpinner(button) { 245 | const spinnerAlt = document.createElement('span') 246 | spinnerAlt.className = 'sr-only' 247 | spinnerAlt.innerText = 'Working...' 248 | 249 | const spinner = document.createElement('div') 250 | spinner.className = 'spinner-border spinner-border-sm text-light' 251 | spinner.dataset.role = 'status' 252 | spinner.appendChild(spinnerAlt) 253 | 254 | button.disabled = true 255 | button.innerText = '' 256 | button.appendChild(spinner) 257 | } 258 | 259 | const getSerialNumber = () => { 260 | const serialNumberSelector = document.getElementById('plus_nft-serial-number-selector') 261 | if (!serialNumberSelector) return undefined 262 | 263 | return Number(serialNumberSelector.value) 264 | } 265 | 266 | const createTransferNftButton = (postHashHex) => { 267 | const button = document.createElement('button') 268 | button.id = transferNftButtonId 269 | button.type = 'button' 270 | button.className = 'btn btn-info font-weight-bold br-8px fs-13px mx-3' 271 | button.innerText = 'Transfer NFT' 272 | button.onclick = () => window.location.href = `/nft/${postHashHex}/transfer` 273 | return button 274 | } 275 | 276 | function createCustomPageHeaderElement(text) { 277 | const headerElement = document.createElement('div') 278 | headerElement.className = 'd-flex align-items-center fs-15px fc-muted p-15px background-color-light-grey' 279 | headerElement.innerText = text 280 | return headerElement 281 | } 282 | 283 | function addPostToBody(post, confirmTextElement, notFoundElement, buttons, singleEntryDisplayed) { 284 | getProfileByPublicKey(post['PosterPublicKeyBase58Check']) 285 | .then(profile => { 286 | const username = profile['Username'] 287 | try { 288 | const serialNumberSelector = createSerialNumberSelector() 289 | createNftPostElement(post, notFoundElement, username, buttons, serialNumberSelector, singleEntryDisplayed) 290 | onSerialNumberSelected() 291 | } catch (e) { 292 | confirmTextElement.innerText = 'You don\'t own this NFT' 293 | } 294 | }) 295 | } 296 | 297 | const search = (text, cb) => { 298 | if (text.startsWith(DESO_PUBLIC_KEY_PREFIX)) { 299 | return getProfileByPublicKey(text).then(profile => { 300 | const profiles = [profile] 301 | return cb(profiles) 302 | }) 303 | } else { 304 | return searchUsernames(text, profiles => { 305 | return cb(profiles) 306 | }) 307 | } 308 | } 309 | 310 | const getLookupKey = (item, text) => { 311 | if (text.startsWith(DESO_PUBLIC_KEY_PREFIX)) { 312 | return item['PublicKeyBase58Check'] 313 | } else { 314 | return item['Username'] 315 | } 316 | } 317 | 318 | const addAutocomplete = (input) => { 319 | const tribute = new Tribute({ 320 | autocompleteMode: true, 321 | replaceTextSuffix: '', 322 | values: (text, cb) => search(text, cb), 323 | menuItemTemplate: (item) => buildTributeUsernameMenuTemplate(item), 324 | loadingItemTemplate: buildLoadingItemTemplate(), 325 | fillAttr: 'Username', 326 | lookup: (item, text) => getLookupKey(item, text) 327 | }) 328 | tribute.attach(input) 329 | } 330 | 331 | const createNftTransfersSearchArea = () => { 332 | const searchBarIcon = document.createElement('i') 333 | searchBarIcon.className = 'icon-search' 334 | 335 | const searchBarIconSpan = document.createElement('span') 336 | searchBarIconSpan.className = 'input-group-text search-bar__icon' 337 | searchBarIconSpan.style.borderTopLeftRadius = '0.25rem' 338 | searchBarIconSpan.style.borderBottomLeftRadius = '0.25rem' 339 | searchBarIconSpan.appendChild(searchBarIcon) 340 | 341 | const input = document.createElement('input') 342 | input.id = 'plus-nft-recipient-input' 343 | input.type = 'text' 344 | input.placeholder = 'Search' 345 | input.className = 'form-control shadow-none search-bar__fix-active' 346 | input.style.fontSize = '15px' 347 | input.style.paddingLeft = '0' 348 | input.style.borderLeftColor = 'rgba(0, 0, 0, 0)' 349 | 350 | const inputGroupPrepend = document.createElement('div') 351 | inputGroupPrepend.className = 'input-group-prepend w-100' 352 | inputGroupPrepend.appendChild(searchBarIconSpan) 353 | inputGroupPrepend.appendChild(input) 354 | 355 | const inputGroup = document.createElement('div') 356 | inputGroup.className = 'input-group' 357 | inputGroup.appendChild(inputGroupPrepend) 358 | 359 | const innerDiv = document.createElement('div') 360 | innerDiv.className = 'd-flex align-items-center w-100 text-grey8A fs-15px global__top-bar__height' 361 | innerDiv.appendChild(inputGroup) 362 | 363 | const searchBar = document.createElement('div') 364 | searchBar.className = 'w-100 global__top-bar__height' 365 | searchBar.appendChild(innerDiv) 366 | 367 | const userSelectDiv = document.createElement('div') 368 | userSelectDiv.className = 'fs-15px font-weight-bold mt-4 px-15px' 369 | userSelectDiv.innerText = 'Recipient public key or username' 370 | userSelectDiv.appendChild(searchBar) 371 | 372 | addAutocomplete(input) 373 | 374 | return userSelectDiv 375 | } 376 | 377 | const showConfirmTransferDialog = (text) => { 378 | return Swal.fire({ 379 | title: 'Transfer NFT?', 380 | icon: 'warning', 381 | text: `Are you sure you want to transfer this NFT to ${text}? This cannot be undone.`, 382 | confirmButtonText: 'Transfer', 383 | showCancelButton: true, 384 | reverseButtons: true, 385 | customClass: { 386 | confirmButton: 'btn btn-light', 387 | cancelButton: 'btn btn-light no' 388 | } 389 | }) 390 | } 391 | 392 | const showUnlockableTextDialog = () => { 393 | let config = { 394 | title: 'Unlockable content', 395 | showConfirmButton: true, 396 | customClass: { 397 | confirmButton: 'btn btn-light', 398 | cancelButton: 'btn btn-light no' 399 | } 400 | } 401 | 402 | config = { 403 | ...config, 404 | input: 'textarea', 405 | inputLabel: 'Add or edit the unlockable content.', 406 | inputPlaceholder: 'Enter URL, code to redeem, link, etc...', 407 | inputValue: transferNftUnlockableText, 408 | confirmButtonText: 'Set', 409 | showCancelButton: true, 410 | reverseButtons: true, 411 | focusConfirm: false, 412 | inputValidator: (value) => { 413 | if (!value) { 414 | return 'You need to write something!' 415 | } 416 | } 417 | } 418 | 419 | return Swal.fire(config) 420 | } 421 | 422 | const sendEncryptMsg = (recipientPublicKey, text) => { 423 | const identity = getCurrentIdentity() 424 | 425 | const payload = { 426 | recipientPublicKey: recipientPublicKey, 427 | message: text 428 | } 429 | 430 | if (identity) { 431 | payload.accessLevel = identity.accessLevel 432 | payload.accessLevelHmac = identity.accessLevelHmac 433 | payload.encryptedSeedHex = identity.encryptedSeedHex 434 | } 435 | 436 | pendingIdentityMessageId = uuid() 437 | 438 | postIdentityMessage(pendingIdentityMessageId, 'encrypt', payload) 439 | } 440 | 441 | const sendDecryptMsg = (text, lastOwnerPublicKey) => { 442 | const identity = getCurrentIdentity() 443 | 444 | const payload = { 445 | encryptedMessages: [{ 446 | EncryptedHex: text, 447 | PublicKey: lastOwnerPublicKey 448 | }] 449 | } 450 | 451 | if (identity) { 452 | payload.accessLevel = identity.accessLevel 453 | payload.accessLevelHmac = identity.accessLevelHmac 454 | payload.encryptedSeedHex = identity.encryptedSeedHex 455 | } 456 | 457 | pendingIdentityMessageId = uuid() 458 | postIdentityMessage(pendingIdentityMessageId, 'decrypt', payload) 459 | } 460 | 461 | const getNftTransferRecipientFromInput = () => { 462 | const input = document.getElementById('plus-nft-recipient-input') 463 | if (!input) return undefined 464 | 465 | return input.value && input.value.trim() 466 | } 467 | 468 | const getRecipientProfileFromInput = () => { 469 | const recipient = getNftTransferRecipientFromInput() 470 | if (!recipient || recipient.length === 0) return undefined 471 | const textIsPublicKey = recipient.startsWith(DESO_PUBLIC_KEY_PREFIX) 472 | return textIsPublicKey ? getProfileByPublicKey(recipient) : getProfileByUsername(recipient) 473 | } 474 | 475 | const restoreTransferButton = () => { 476 | const button = document.getElementById(transferNftButtonId) 477 | button.disabled = false 478 | button.innerText = 'Transfer' 479 | } 480 | 481 | const initTransfer = (senderPublicKey, postHashHex, recipient, encryptedUnlockableText) => { 482 | showConfirmTransferDialog(recipient) 483 | .then((res) => { 484 | if (res.isConfirmed) { 485 | const textIsPublicKey = recipient.startsWith(DESO_PUBLIC_KEY_PREFIX) 486 | return textIsPublicKey ? getProfileByPublicKey(recipient) : getProfileByUsername(recipient) 487 | } else { 488 | restoreTransferButton() 489 | } 490 | }) 491 | .then(profile => { 492 | const recipientPublicKey = profile['PublicKeyBase58Check'] 493 | if (!recipientPublicKey) return Promise.reject(`Unable to retrieve profile for ${recipient}`) 494 | const serialNumber = getSerialNumber() 495 | return transferNft(senderPublicKey, recipientPublicKey, postHashHex, serialNumber, encryptedUnlockableText) 496 | }) 497 | .then(signTransaction) 498 | .catch(err => { 499 | console.error(err) 500 | restoreTransferButton() 501 | }) 502 | } 503 | 504 | const onNftTransferUnlockableEncrypted = (encryptedUnlockableText) => { 505 | const recipient = getNftTransferRecipientFromInput() 506 | if (!recipient || recipient.length === 0) return 507 | 508 | const publicKey = getLoggedInPublicKey() 509 | const postHashHex = getPostHashHexFromUrl() 510 | if (!publicKey || !postHashHex) return 511 | 512 | initTransfer(publicKey, postHashHex, recipient, encryptedUnlockableText) 513 | } 514 | 515 | const onNftTransferUnlockableDecrypted = (unlockableText) => { 516 | transferNftUnlockableText = unlockableText 517 | } 518 | 519 | const createTransferButton = (senderPublicKey, postHashHex) => { 520 | const transferButton = document.createElement('button') 521 | transferButton.id = transferNftButtonId 522 | transferButton.type = 'button' 523 | transferButton.className = 'btn btn-primary font-weight-bold br-8px fs-13px ml-3' 524 | transferButton.innerText = 'Transfer' 525 | transferButton.onclick = () => { 526 | const recipient = getNftTransferRecipientFromInput() 527 | if (!recipient || recipient.length === 0) return 528 | 529 | showSpinner(transferButton) 530 | 531 | const hasUnlockable = transferNftPostEntry['HasUnlockable'] 532 | 533 | if (hasUnlockable) { 534 | getRecipientProfileFromInput().then(profile => { 535 | const recipientPublicKey = profile['PublicKeyBase58Check'] 536 | if (transferNftUnlockableText) { 537 | // Already have decrypted unlockable text 538 | sendEncryptMsg(recipientPublicKey, transferNftUnlockableText) 539 | } else { 540 | // Entry requires unlockable text, but we don't have any 541 | showUnlockableTextDialog().then(res => { 542 | if (res.isConfirmed) { 543 | sendEncryptMsg(recipientPublicKey, res.value) 544 | } else { 545 | restoreTransferButton() 546 | } 547 | }) 548 | } 549 | }) 550 | } else { 551 | initTransfer(senderPublicKey, postHashHex, recipient) 552 | } 553 | } 554 | 555 | return transferButton 556 | } 557 | 558 | const createTransferNftPage = () => { 559 | transferNftUnlockableText = null 560 | transferNftPostEntry = null 561 | transferNftOwnedEntries = [] 562 | 563 | setCustomPageTopBarTitle('Transfer an NFT') 564 | 565 | const notFoundElement = getCustomPageNotFoundElement() 566 | 567 | const headerElement = createCustomPageHeaderElement( 568 | 'The recipient may accept the transfer, return it, or burn the NFT to reject it. You cannot undo this.' 569 | ) 570 | notFoundElement.appendChild(headerElement) 571 | 572 | const userSelectDiv = createNftTransfersSearchArea() 573 | notFoundElement.appendChild(userSelectDiv) 574 | 575 | const postHashHex = getPostHashHexFromUrl() 576 | const publicKey = getLoggedInPublicKey() 577 | if (!publicKey || !postHashHex) return 578 | 579 | getBidsForNftPost(publicKey, postHashHex) 580 | .then(res => { 581 | transferNftPostEntry = res['PostEntryResponse'] 582 | transferNftOwnedEntries = res['NFTEntryResponses'].filter(entry => entry['OwnerPublicKeyBase58Check'] === publicKey) 583 | const transferButton = createTransferButton(publicKey, postHashHex) 584 | addPostToBody(transferNftPostEntry, headerElement, notFoundElement, [transferButton], true) 585 | 586 | let params = (new URL(document.location)).searchParams 587 | let recipientPublicKey = params.get("publicKey") 588 | if (recipientPublicKey) return getProfileByPublicKey(recipientPublicKey) 589 | }) 590 | .then(profile => { 591 | if (profile) { 592 | const input = document.getElementById('plus-nft-recipient-input') 593 | if (input) input.value = profile['Username'] 594 | } 595 | }) 596 | } 597 | 598 | const addNftExtraDataToNftPage = (nftPostPage) => { 599 | const extraDataLinkId = 'plus_nft-extra-data-link' 600 | if (document.getElementById(extraDataLinkId)) return 601 | 602 | const postHashHex = getPostHashHexFromUrl() 603 | getSinglePost(postHashHex) 604 | .then(data => data['PostFound']['PostExtraData']) 605 | .then(postExtraData => { 606 | if (!postExtraData || isEmpty(postExtraData) || document.getElementById(extraDataLinkId)) return 607 | 608 | const json = document.createElement('pre') 609 | json.innerText = JSON.stringify(postExtraData, null, 2) 610 | json.className = 'plus-text-primary d-none p-3 rounded mt-2 border-secondary' 611 | json.style.border = '1px solid' 612 | json.style.whiteSpace = 'pre' 613 | 614 | const link = document.createElement('div') 615 | link.id = extraDataLinkId 616 | link.className = 'cursor-pointer' 617 | link.innerText = "Show Extra Data" 618 | link.onclick = () => { 619 | if (json.classList.contains('d-none')) { 620 | json.classList.remove('d-none') 621 | link.innerText = "Hide Extra Data" 622 | } else { 623 | json.classList.add('d-none') 624 | link.innerText = "Show Extra Data" 625 | } 626 | } 627 | 628 | const footer = getNftPostPageFooter(nftPostPage) 629 | footer?.appendChild(link) 630 | footer?.appendChild(json) 631 | }) 632 | .catch() 633 | } 634 | -------------------------------------------------------------------------------- /vendor/tribute/tribute.min.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).Tribute=t()}(this,(function(){"use strict";function e(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function t(e,t){for(var n=0;ne.length)&&(t=e.length);for(var n=0,i=new Array(t);n>>0,r=arguments[1],o=0;o container for the click");n.selectItemAtIndex(i.getAttribute("data-index"),t),n.hideMenu()}else n.current.element&&!n.current.externalTrigger&&(n.current.externalTrigger=!1,setTimeout((function(){return n.hideMenu()})))}},{key:"keyup",value:function(e,t){if(e.inputEvent&&(e.inputEvent=!1),e.updateSelection(this),27!==t.keyCode){if(!e.tribute.allowSpaces&&e.tribute.hasTrailingSpace)return e.tribute.hasTrailingSpace=!1,e.commandEvent=!0,void e.callbacks().space(t,this);if(!e.tribute.isActive)if(e.tribute.autocompleteMode)e.callbacks().triggerChar(t,this,"");else{var n=e.getKeyCode(e,this,t);if(isNaN(n)||!n)return;var i=e.tribute.triggers().find((function(e){return e.charCodeAt(0)===n}));void 0!==i&&e.callbacks().triggerChar(t,this,i)}e.tribute.current.mentionText.length=r.current.collection.menuShowMinLength&&r.inputEvent&&r.showMenuFor(n,!0)},enter:function(t,n){e.tribute.isActive&&e.tribute.current.filteredItems&&(t.preventDefault(),t.stopPropagation(),setTimeout((function(){e.tribute.selectItemAtIndex(e.tribute.menuSelected,t),e.tribute.hideMenu()}),0))},escape:function(t,n){e.tribute.isActive&&(t.preventDefault(),t.stopPropagation(),e.tribute.isActive=!1,e.tribute.hideMenu())},tab:function(t,n){e.callbacks().enter(t,n)},space:function(t,n){e.tribute.isActive&&(e.tribute.spaceSelectsMatch?e.callbacks().enter(t,n):e.tribute.allowSpaces||(t.stopPropagation(),setTimeout((function(){e.tribute.hideMenu(),e.tribute.isActive=!1}),0)))},up:function(t,n){if(e.tribute.isActive&&e.tribute.current.filteredItems){t.preventDefault(),t.stopPropagation();var i=e.tribute.current.filteredItems.length,r=e.tribute.menuSelected;i>r&&r>0?(e.tribute.menuSelected--,e.setActiveLi()):0===r&&(e.tribute.menuSelected=i-1,e.setActiveLi(),e.tribute.menu.scrollTop=e.tribute.menu.scrollHeight)}},down:function(t,n){if(e.tribute.isActive&&e.tribute.current.filteredItems){t.preventDefault(),t.stopPropagation();var i=e.tribute.current.filteredItems.length-1,r=e.tribute.menuSelected;i>r?(e.tribute.menuSelected++,e.setActiveLi()):i===r&&(e.tribute.menuSelected=0,e.setActiveLi(),e.tribute.menu.scrollTop=0)}},delete:function(t,n){e.tribute.isActive&&e.tribute.current.mentionText.length<1?e.tribute.hideMenu():e.tribute.isActive&&e.tribute.showMenuFor(n)}}}},{key:"setActiveLi",value:function(e){var t=this.tribute.menu.querySelectorAll("li"),n=t.length>>>0;e&&(this.tribute.menuSelected=parseInt(e));for(var i=0;iu.bottom){var l=o.bottom-u.bottom;this.tribute.menu.scrollTop+=l}else if(o.topi.width&&(r.left||r.right),u=window.innerHeight>i.height&&(r.top||r.bottom);(o||u)&&(n.tribute.menu.style.cssText="display: none",n.positionMenuAtCaret(e))}),0)}else this.tribute.menu.style.cssText="display: none"}},{key:"selectElement",value:function(e,t,n){var i,r=e;if(t)for(var o=0;o=0&&(t=i.substring(0,r))}}else{var o=this.tribute.current.element;if(o){var u=o.selectionStart;o.value&&u>=0&&(t=o.value.substring(0,u))}}return t}},{key:"getLastWordInText",value:function(e){var t;return e=e.replace(/\u00A0/g," "),(t=this.tribute.autocompleteSeparator?e.split(this.tribute.autocompleteSeparator):e.split(/\s+/))[t.length-1].trim()}},{key:"getTriggerInfo",value:function(e,t,n,i,r){var o,u,l,a=this,s=this.tribute.current;if(this.isContentEditable(s.element)){var c=this.getContentEditableSelectedPath(s);c&&(o=c.selected,u=c.path,l=c.offset)}else o=this.tribute.current.element;var h=this.getTextPrecedingCurrentSelection(),d=this.getLastWordInText(h);if(r)return{mentionPosition:h.length-d.length,mentionText:d,mentionSelectedElement:o,mentionSelectedPath:u,mentionSelectedOffset:l};if(null!=h){var f,m=-1;if(this.tribute.collection.forEach((function(e){var t=e.trigger,i=e.requireLeadingSpace?a.lastIndexWithLeadingSpace(h,t):h.lastIndexOf(t);i>m&&(m=i,f=t,n=e.requireLeadingSpace)})),m>=0&&(0===m||!n||/[\xA0\s]/g.test(h.substring(m-1,m)))){var p=h.substring(m+f.length,h.length);f=h.substring(m,m+f.length);var v=p.substring(0,1),g=p.length>0&&(" "===v||" "===v);t&&(p=p.trim());var b=i?/[^\S ]/g:/[\xA0\s]/g;if(this.tribute.hasTrailingSpace=b.test(p),!g&&(e||!b.test(p)))return{mentionPosition:m,mentionText:p,mentionSelectedElement:o,mentionSelectedPath:u,mentionSelectedOffset:l,mentionTriggerChar:f}}}}},{key:"lastIndexWithLeadingSpace",value:function(e,t){for(var n=e.split("").reverse().join(""),i=-1,r=0,o=e.length;r=0;s--)if(t[s]!==n[r-s]){a=!1;break}if(a&&(u||l)){i=e.length-1-r;break}}return i}},{key:"isContentEditable",value:function(e){return"INPUT"!==e.nodeName&&"TEXTAREA"!==e.nodeName}},{key:"isMenuOffScreen",value:function(e,t){var n=window.innerWidth,i=window.innerHeight,r=document.documentElement,o=(window.pageXOffset||r.scrollLeft)-(r.clientLeft||0),u=(window.pageYOffset||r.scrollTop)-(r.clientTop||0),l="number"==typeof e.top?e.top:u+i-e.bottom-t.height,a="number"==typeof e.right?e.right:e.left+t.width,s="number"==typeof e.bottom?e.bottom:e.top+t.height,c="number"==typeof e.left?e.left:o+n-e.right-t.width;return{top:lMath.ceil(o+n),bottom:s>Math.ceil(u+i),left:cparseInt(u.height)&&(o.overflowY="scroll")):o.overflow="hidden",r.textContent=e.value.substring(0,t),"INPUT"===e.nodeName&&(r.textContent=r.textContent.replace(/\s/g," "));var l=this.getDocument().createElement("span");l.textContent=e.value.substring(t)||".",r.appendChild(l);var a=e.getBoundingClientRect(),s=document.documentElement,c=(window.pageXOffset||s.scrollLeft)-(s.clientLeft||0),h=(window.pageYOffset||s.scrollTop)-(s.clientTop||0),d=0,f=0;this.menuContainerIsBody&&(d=a.top,f=a.left);var m={top:d+h+l.offsetTop+parseInt(u.borderTopWidth)+parseInt(u.fontSize)-e.scrollTop,left:f+c+l.offsetLeft+parseInt(u.borderLeftWidth)},p=window.innerWidth,v=window.innerHeight,g=this.getMenuDimensions(),b=this.isMenuOffScreen(m,g);b.right&&(m.right=p-m.left,m.left="auto");var y=this.tribute.menuContainer?this.tribute.menuContainer.offsetHeight:this.getDocument().body.offsetHeight;if(b.bottom){var w=y-(v-(this.tribute.menuContainer?this.tribute.menuContainer.getBoundingClientRect():this.getDocument().body.getBoundingClientRect()).top);m.bottom=w+(v-a.top-l.offsetTop),m.top="auto"}return(b=this.isMenuOffScreen(m,g)).left&&(m.left=p>g.width?c+p-g.width:c,delete m.right),b.top&&(m.top=v>g.height?h+v-g.height:h,delete m.bottom),this.getDocument().body.removeChild(r),m}},{key:"getContentEditableCaretPosition",value:function(e){var t,n=this.getWindowSelection();(t=this.getDocument().createRange()).setStart(n.anchorNode,e),t.setEnd(n.anchorNode,e),t.collapse(!1);var i=t.getBoundingClientRect(),r=document.documentElement,o=(window.pageXOffset||r.scrollLeft)-(r.clientLeft||0),u=(window.pageYOffset||r.scrollTop)-(r.clientTop||0),l={left:i.left+o,top:i.top+i.height+u},a=window.innerWidth,s=window.innerHeight,c=this.getMenuDimensions(),h=this.isMenuOffScreen(l,c);h.right&&(l.left="auto",l.right=a-i.left-o);var d=this.tribute.menuContainer?this.tribute.menuContainer.offsetHeight:this.getDocument().body.offsetHeight;if(h.bottom){var f=d-(s-(this.tribute.menuContainer?this.tribute.menuContainer.getBoundingClientRect():this.getDocument().body.getBoundingClientRect()).top);l.top="auto",l.bottom=f+(s-i.top)}return(h=this.isMenuOffScreen(l,c)).left&&(l.left=a>c.width?o+a-c.width:o,delete l.right),h.top&&(l.top=s>c.height?u+s-c.height:u,delete l.bottom),this.menuContainerIsBody||(l.left=l.left?l.left-this.tribute.menuContainer.offsetLeft:l.left,l.top=l.top?l.top-this.tribute.menuContainer.offsetTop:l.top),l}},{key:"scrollIntoView",value:function(e){var t,n=this.menu;if(void 0!==n){for(;void 0===t||0===t.height;)if(0===(t=n.getBoundingClientRect()).height&&(void 0===(n=n.childNodes[0])||!n.getBoundingClientRect))return;var i=t.top,r=i+t.height;if(i<0)window.scrollTo(0,window.pageYOffset+t.top-20);else if(r>window.innerHeight){var o=window.pageYOffset+t.top-20;o-window.pageYOffset>100&&(o=window.pageYOffset+100);var u=window.pageYOffset-(window.innerHeight-r);u>o&&(u=o),window.scrollTo(0,u)}}}},{key:"menuContainerIsBody",get:function(){return this.tribute.menuContainer===document.body||!this.tribute.menuContainer}}]),t}(),s=function(){function t(n){e(this,t),this.tribute=n,this.tribute.search=this}return n(t,[{key:"simpleFilter",value:function(e,t){var n=this;return t.filter((function(t){return n.test(e,t)}))}},{key:"test",value:function(e,t){return null!==this.match(e,t)}},{key:"match",value:function(e,t,n){n=n||{};t.length;var i=n.pre||"",r=n.post||"",o=n.caseSensitive&&t||t.toLowerCase();if(n.skip)return{rendered:t,score:0};e=n.caseSensitive&&e||e.toLowerCase();var u=this.traverse(o,e,0,0,[]);return u?{rendered:this.render(t,u.cache,i,r),score:u.score}:null}},{key:"traverse",value:function(e,t,n,i,r){if(this.tribute.autocompleteSeparator&&(t=t.split(this.tribute.autocompleteSeparator).splice(-1)[0]),t.length===i)return{score:this.calculateScore(r),cache:r.slice()};if(!(e.length===n||t.length-i>e.length-n)){for(var o,u,l=t[i],a=e.indexOf(l,n);a>-1;){if(r.push(a),u=this.traverse(e,t,a+1,i+1,r),r.pop(),!u)return o;(!o||o.score0&&(e[r-1]+1===i?n+=n+1:n=1),t+=n})),t}},{key:"render",value:function(e,t,n,i){var r=e.substring(0,t[0]);return t.forEach((function(o,u){r+=n+e[o]+i+e.substring(o+1,t[u+1]?t[u+1]:e.length)})),r}},{key:"filter",value:function(e,t,n){var i=this;return n=n||{},t.reduce((function(t,r,o,u){var l=r;n.extract&&((l=n.extract(r))||(l=""));var a=i.match(e,l,n);return null!=a&&(t[t.length]={string:a.rendered,score:a.score,index:o,original:r}),t}),[]).sort((function(e,t){var n=t.score-e.score;return n||e.index-t.index}))}}]),t}();return function(){function t(n){var i,r=this,o=n.values,c=void 0===o?null:o,h=n.loadingItemTemplate,d=void 0===h?null:h,f=n.iframe,m=void 0===f?null:f,p=n.selectClass,v=void 0===p?"highlight":p,g=n.containerClass,b=void 0===g?"tribute-container":g,y=n.itemClass,w=void 0===y?"":y,T=n.trigger,C=void 0===T?"@":T,S=n.autocompleteMode,E=void 0!==S&&S,k=n.autocompleteSeparator,x=void 0===k?null:k,M=n.selectTemplate,A=void 0===M?null:M,L=n.menuItemTemplate,I=void 0===L?null:L,N=n.lookup,O=void 0===N?"key":N,D=n.fillAttr,P=void 0===D?"value":D,R=n.collection,W=void 0===R?null:R,H=n.menuContainer,B=void 0===H?null:H,F=n.noMatchTemplate,_=void 0===F?null:F,j=n.requireLeadingSpace,Y=void 0===j||j,z=n.allowSpaces,K=void 0!==z&&z,q=n.replaceTextSuffix,U=void 0===q?null:q,X=n.positionMenu,Q=void 0===X||X,V=n.spaceSelectsMatch,$=void 0!==V&&V,G=n.searchOpts,J=void 0===G?{}:G,Z=n.menuItemLimit,ee=void 0===Z?null:Z,te=n.menuShowMinLength,ne=void 0===te?0:te;if(e(this,t),this.autocompleteMode=E,this.autocompleteSeparator=x,this.menuSelected=0,this.current={},this.inputEvent=!1,this.isActive=!1,this.menuContainer=B,this.allowSpaces=K,this.replaceTextSuffix=U,this.positionMenu=Q,this.hasTrailingSpace=!1,this.spaceSelectsMatch=$,this.autocompleteMode&&(C="",K=!1),c)this.collection=[{trigger:C,iframe:m,selectClass:v,containerClass:b,itemClass:w,selectTemplate:(A||t.defaultSelectTemplate).bind(this),menuItemTemplate:(I||t.defaultMenuItemTemplate).bind(this),noMatchTemplate:(i=_,"string"==typeof i?""===i.trim()?null:i:"function"==typeof i?i.bind(r):_||function(){return"
  • No Match Found!
  • "}.bind(r)),lookup:O,fillAttr:P,values:c,loadingItemTemplate:d,requireLeadingSpace:Y,searchOpts:J,menuItemLimit:ee,menuShowMinLength:ne}];else{if(!W)throw new Error("[Tribute] No collection specified.");this.autocompleteMode&&console.warn("Tribute in autocomplete mode does not work for collections"),this.collection=W.map((function(e){return{trigger:e.trigger||C,iframe:e.iframe||m,selectClass:e.selectClass||v,containerClass:e.containerClass||b,itemClass:e.itemClass||w,selectTemplate:(e.selectTemplate||t.defaultSelectTemplate).bind(r),menuItemTemplate:(e.menuItemTemplate||t.defaultMenuItemTemplate).bind(r),noMatchTemplate:function(e){return"string"==typeof e?""===e.trim()?null:e:"function"==typeof e?e.bind(r):_||function(){return"
  • No Match Found!
  • "}.bind(r)}(_),lookup:e.lookup||O,fillAttr:e.fillAttr||P,values:e.values,loadingItemTemplate:e.loadingItemTemplate,requireLeadingSpace:e.requireLeadingSpace,searchOpts:e.searchOpts||J,menuItemLimit:e.menuItemLimit||ee,menuShowMinLength:e.menuShowMinLength||ne}}))}new a(this),new u(this),new l(this),new s(this)}return n(t,[{key:"triggers",value:function(){return this.collection.map((function(e){return e.trigger}))}},{key:"attach",value:function(e){if(!e)throw new Error("[Tribute] Must pass in a DOM node or NodeList.");if("undefined"!=typeof jQuery&&e instanceof jQuery&&(e=e.get()),e.constructor===NodeList||e.constructor===HTMLCollection||e.constructor===Array)for(var t=e.length,n=0;n",post:n.current.collection.searchOpts.post||"",skip:n.current.collection.searchOpts.skip,extract:function(e){if("string"==typeof n.current.collection.lookup)return e[n.current.collection.lookup];if("function"==typeof n.current.collection.lookup)return n.current.collection.lookup(e,n.current.mentionText);throw new Error("Invalid lookup attribute, lookup must be string or function.")}});n.current.collection.menuItemLimit&&(r=r.slice(0,n.current.collection.menuItemLimit)),n.current.filteredItems=r;var o=n.menu.querySelector("ul");if(n.range.positionMenuAtCaret(t),!r.length){var u=new CustomEvent("tribute-no-match",{detail:n.menu});return n.current.element.dispatchEvent(u),void("function"==typeof n.current.collection.noMatchTemplate&&!n.current.collection.noMatchTemplate()||!n.current.collection.noMatchTemplate?n.hideMenu():"function"==typeof n.current.collection.noMatchTemplate?o.innerHTML=n.current.collection.noMatchTemplate():o.innerHTML=n.current.collection.noMatchTemplate)}o.innerHTML="";var l=n.range.getDocument().createDocumentFragment();r.forEach((function(e,t){var r=n.range.getDocument().createElement("li");r.setAttribute("data-index",t),r.className=n.current.collection.itemClass,r.addEventListener("mousemove",(function(e){var t=i(n._findLiTarget(e.target),2),r=(t[0],t[1]);0!==e.movementY&&n.events.setActiveLi(r)})),n.menuSelected===t&&r.classList.add(n.current.collection.selectClass),r.innerHTML=n.current.collection.menuItemTemplate(e),l.appendChild(r)})),o.appendChild(l)}};"function"==typeof this.current.collection.values?(this.current.collection.loadingItemTemplate&&(this.menu.querySelector("ul").innerHTML=this.current.collection.loadingItemTemplate,this.range.positionMenuAtCaret(t)),this.current.collection.values(this.current.mentionText,r)):r(this.current.collection.values)}}},{key:"_findLiTarget",value:function(e){if(!e)return[];var t=e.getAttribute("data-index");return t?[e,t]:this._findLiTarget(e.parentNode)}},{key:"showMenuForCollection",value:function(e,t){e!==document.activeElement&&this.placeCaretAtEnd(e),this.current.collection=this.collection[t||0],this.current.externalTrigger=!0,this.current.element=e,e.isContentEditable?this.insertTextAtCursor(this.current.collection.trigger):this.insertAtCaret(e,this.current.collection.trigger),this.showMenuFor(e)}},{key:"placeCaretAtEnd",value:function(e){if(e.focus(),void 0!==window.getSelection&&void 0!==document.createRange){var t=document.createRange();t.selectNodeContents(e),t.collapse(!1);var n=window.getSelection();n.removeAllRanges(),n.addRange(t)}else if(void 0!==document.body.createTextRange){var i=document.body.createTextRange();i.moveToElementText(e),i.collapse(!1),i.select()}}},{key:"insertTextAtCursor",value:function(e){var t,n;(n=(t=window.getSelection()).getRangeAt(0)).deleteContents();var i=document.createTextNode(e);n.insertNode(i),n.selectNodeContents(i),n.collapse(!1),t.removeAllRanges(),t.addRange(n)}},{key:"insertAtCaret",value:function(e,t){var n=e.scrollTop,i=e.selectionStart,r=e.value.substring(0,i),o=e.value.substring(e.selectionEnd,e.value.length);e.value=r+t+o,i+=t.length,e.selectionStart=i,e.selectionEnd=i,e.focus(),e.scrollTop=n}},{key:"hideMenu",value:function(){this.menu&&(this.menu.style.cssText="display: none;",this.isActive=!1,this.menuSelected=0,this.current={})}},{key:"selectItemAtIndex",value:function(e,t){if("number"==typeof(e=parseInt(e))&&!isNaN(e)){var n=this.current.filteredItems[e],i=this.current.collection.selectTemplate(n);null!==i&&this.replaceText(i,t,n)}}},{key:"replaceText",value:function(e,t,n){this.range.replaceTriggerText(e,!0,!0,t,n)}},{key:"_append",value:function(e,t,n){if("function"==typeof e.values)throw new Error("Unable to append to values, as it is a function.");e.values=n?t:e.values.concat(t)}},{key:"append",value:function(e,t,n){var i=parseInt(e);if("number"!=typeof i)throw new Error("please provide an index for the collection to update.");var r=this.collection[i];this._append(r,t,n)}},{key:"appendCurrent",value:function(e,t){if(!this.isActive)throw new Error("No active state. Please use append instead and pass an index.");this._append(this.current.collection,e,t)}},{key:"detach",value:function(e){if(!e)throw new Error("[Tribute] Must pass in a DOM node or NodeList.");if("undefined"!=typeof jQuery&&e instanceof jQuery&&(e=e.get()),e.constructor===NodeList||e.constructor===HTMLCollection||e.constructor===Array)for(var t=e.length,n=0;n'+(this.current.collection.trigger+e.original[this.current.collection.fillAttr])+"":this.current.collection.trigger+e.original[this.current.collection.fillAttr]}},{key:"defaultMenuItemTemplate",value:function(e){return e.string}},{key:"inputTypes",value:function(){return["TEXTAREA","INPUT"]}}]),t}()})); 2 | //# sourceMappingURL=tribute.min.js.map 3 | --------------------------------------------------------------------------------