nostr bookmark

    @@ -7,7 +7,7 @@ */ (() => { - const website = 'https://nostr-web-bookmark-trend.vercel.app/'; + const website = 'https://kuchiyose.vercel.app/'; const nostrToolsUrl = 'https://unpkg.com/nostr-tools/lib/nostr.bundle.js'; const indexerRelays = [ 'wss://directory.yabu.me/',
  • /*
     * @title nostr bookmark
     * @description 現在のWebページをNostrでブックマーク
     * @include http://*
     * @license CC0 1.0
     * @javascript_url
     */
    
    (() => {
    	const website = 'https://kuchiyose.vercel.app/';
    	const nostrToolsUrl = 'https://unpkg.com/nostr-tools/lib/nostr.bundle.js';
    	const indexerRelays = [
    		'wss://directory.yabu.me/',
    		'wss://purplepag.es/',
    		'wss://indexer.coracle.social/'
    	];
    	const now = Math.floor(Date.now() / 1000);
    
    	const getPubkey = async () => {
    		let pubkey;
    		if (window.nostr?.getPublicKey) {
    			try {
    				pubkey = await window.nostr.getPublicKey();
    			} catch (error) {
    				console.warn(error);
    			}
    		}
    		return pubkey;
    	};
    
    	const getReplaceableEvent = (pool, relays, filter) => {
    		return new Promise((resolve) => {
    			let event;
    			const sub = pool.subscribe(relays, filter, {
    				onevent(ev) {
    					if (event === undefined || event.created_at < ev.created_at) {
    						event = ev;
    					}
    				},
    				oneose() {
    					sub.close();
    					resolve(event);
    				}
    			});
    		});
    	};
    
    	const getRelaysToUseFromKind10002Event = (event) => {
    		const newRelays = {};
    		for (const tag of event?.tags.filter(
    			(tag) => tag.length >= 2 && tag[0] === 'r' && URL.canParse(tag[1])
    		) ?? []) {
    			const url = window.NostrTools.utils.normalizeURL(tag[1]);
    			const isRead = tag.length === 2 || tag[2] === 'read';
    			const isWrite = tag.length === 2 || tag[2] === 'write';
    			if (newRelays[url] === undefined) {
    				newRelays[url] = {
    					read: isRead,
    					write: isWrite
    				};
    			} else {
    				if (isRead) {
    					newRelays[url].read = true;
    				}
    				if (isWrite) {
    					newRelays[url].write = true;
    				}
    			}
    		}
    		return newRelays;
    	};
    
    	const getRelays = (relayRecord, relayType) => {
    		return Array.from(
    			new Set(
    				Object.entries(relayRecord)
    					.filter(([_, obj]) => obj[relayType])
    					.map(([relay, _]) => relay)
    			)
    		);
    	};
    
    	const escapeSpecialChars = (str) => {
    		return str
    			.replace(/&/g, '&amp;')
    			.replace(/</g, '&lt;')
    			.replace(/>/g, '&gt;')
    			.replace(/"/g, '&quot;')
    			.replace(/'/g, '&#039;');
    	};
    
    	const escapeHTML = (strings, ...values) => {
    		return strings.reduce((result, str, i) => {
    			const value = values[i - 1];
    			if (typeof value === 'string') {
    				return result + escapeSpecialChars(value) + str;
    			} else {
    				return result + String(value) + str;
    			}
    		});
    	};
    
    	const showDialogBookmark = (identifier, title, tTags, content, isError) => {
    		const usp = new URLSearchParams({
    			d: identifier,
    			title,
    			content
    		});
    		for (const t of tTags) {
    			usp.append('t', t);
    		}
    		const url = `${website}entry/${identifier}?${usp.toString()}`;
    		const view = escapeHTML`
    			<dialog id="nostr-web-bookmark-trend-dialog-bookmark">
    				<form>
    					<div>
    						<p>${isError ? 'セキュリティ上の理由でブックマークできないため下記のページからブックマークしてください🙇' : ''}</p>
    						<p><a href="${url}" target="_blank" rel="noopener noreferrer">ブックマークページを開く</a></p>
    					</div>
    					<dl>
    						<dt>d-tag</dt>
    						<dd>${identifier}</dd>
    						<dt>title-tag</dt>
    						<dd>${title}</dd>
    						<dt>
    							<label for="nostr-web-bookmark-trend-tag-input">t-tag</label>
    							<button id="nostr-web-bookmark-trend-clear" type="button">Clear</button>
    							<span id="nostr-web-bookmark-trend-tags"></span>
    						</dt>
    						<dd>
    							<input id="nostr-web-bookmark-trend-tag-input" type="text" pattern="[^\\s#]+" />
    							<button
    								id="nostr-web-bookmark-trend-tag-add"
    								type="button"
    								title="add"
    							>+</button>
    						</dd>
    						<dt><label for="nostr-web-bookmark-trend-content">content</label></dt>
    						<dd>
    							<textarea id="nostr-web-bookmark-trend-content"></textarea>
    						</dd>
    						<dt>Submit</dt>
    						<dd>
    							<button value="cancel" formmethod="dialog">Cancel</button>
    							<button id="nostr-web-bookmark-trend-confirm" value="default">Submit</button>
    						</dd>
    					</dl>
    				</form>
    			</dialog>
    		`;
    		const div = document.createElement('div');
    		div.innerHTML = view;
    		document.body.append(div);
    		const css = `
    			dialog#nostr-web-bookmark-trend-dialog-bookmark * {
    				all: revert;
    			}
    			dialog#nostr-web-bookmark-trend-dialog-bookmark {
    				margin: auto;
    				padding: 10px;
    				text-align: left;
    			}
    			input#nostr-web-bookmark-trend-tag-input:invalid {
    				outline: 2px solid red;
    			}
    			textarea#nostr-web-bookmark-trend-content {
    				width: 30em;
    				height: 5em;
    			}
    		`;
    		const style = document.createElement('style');
    		style.textContent = css;
    		document.head.appendChild(style);
    
    		const dialogId = 'nostr-web-bookmark-trend-dialog-bookmark';
    		const clearId = 'nostr-web-bookmark-trend-clear';
    		const tagsId = 'nostr-web-bookmark-trend-tags';
    		const tagInputId = 'nostr-web-bookmark-trend-tag-input';
    		const tagAddId = 'nostr-web-bookmark-trend-tag-add';
    		const contentId = 'nostr-web-bookmark-trend-content';
    		const confirmId = 'nostr-web-bookmark-trend-confirm';
    
    		const dialog = document.getElementById(dialogId);
    		const form = dialog.querySelector('form');
    		const clearButton = dialog.querySelector(`#${clearId}`);
    		const tags = dialog.querySelector(`#${tagsId}`);
    		const tagInput = dialog.querySelector(`#${tagInputId}`);
    		const tagAddButton = dialog.querySelector(`#${tagAddId}`);
    		const contentEl = dialog.querySelector(`#${contentId}`);
    		const confirmButton = dialog.querySelector(`#${confirmId}`);
    		if (isError) {
    			tagInput.disabled = true;
    			tagAddButton.disabled = true;
    			contentEl.readOnly = true;
    			confirmButton.disabled = true;
    		}
    
    		form.addEventListener('keypress', (e) => {
    			if (tagInput === document.activeElement && e.key === 'Enter') {
    				e.preventDefault();
    			}
    		});
    
    		clearButton.addEventListener('click', () => {
    			tTags.length = 0;
    			tagInput.value = '';
    			tagInput.focus();
    			tags.textContent = '';
    		});
    
    		tagAddButton.addEventListener('click', () => {
    			if (tagInput.value.length === 0 || tagInput.validity.patternMismatch) {
    				return;
    			}
    			const v = tagInput.value.toLowerCase();
    			if (tTags.includes(v)) {
    				return;
    			}
    			tTags.push(v);
    			tagInput.value = '';
    			tagInput.focus();
    			tags.textContent = tTags.map((t) => `#${t}`).join(' ');
    		});
    
    		confirmButton.addEventListener('click', (e) => {
    			e.preventDefault();
    			const res = {
    				tTags,
    				content: contentEl.value
    			};
    			dialog.close(JSON.stringify(res));
    		});
    
    		tags.textContent = tTags.map((t) => `#${t}`).join(' ');
    		contentEl.value = content;
    		dialog.showModal();
    
    		return new Promise((resolve) => {
    			dialog.addEventListener('close', (e) => {
    				const r = dialog.returnValue === 'cancel' ? null : dialog.returnValue;
    				resolve(r);
    			});
    		});
    	};
    
    	const showDialogResult = (relays, url) => {
    		const view = escapeHTML`
    			<dialog id="nostr-web-bookmark-trend-dialog-result">
    				<p>以下のリレーに送信しました。</p>
    				<p>${relays.join('\n')}</p>
    				<p><a href="${url}" target="_blank" rel="noopener noreferrer">ブックマークページを開く</a></p>
    				<form>
    					<button value="close" formmethod="dialog">Close</button>
    				</form>
    			</dialog>
    		`;
    		const div = document.createElement('div');
    		div.innerHTML = view;
    		document.body.append(div);
    		const css = `
    			dialog#nostr-web-bookmark-trend-dialog-result * {
    				all: revert;
    			}
    			dialog#nostr-web-bookmark-trend-dialog-result {
    				margin: auto;
    				padding: 10px;
    				text-align: left;
    			}
    			dialog#nostr-web-bookmark-trend-dialog-result > p {
    				white-space: pre-line;
    			}
    		`;
    		const style = document.createElement('style');
    		style.textContent = css;
    		document.head.appendChild(style);
    
    		const dialogId = 'nostr-web-bookmark-trend-dialog-result';
    		const dialog = document.getElementById(dialogId);
    		dialog.showModal();
    
    		return new Promise((resolve) => {
    			dialog.addEventListener('close', (e) => {
    				resolve('close');
    			});
    		});
    	};
    
    	const main = async () => {
    		const pubkey = await getPubkey();
    		if (pubkey === undefined) {
    			return;
    		}
    		const url = new URL(location.href);
    		url.search = '';
    		url.hash = '';
    		const identifier = url.href
    			.replace(/#$/, '')
    			.replace(/\?$/, '')
    			.replace(/^https?:\/\//, '');
    		if (window.NostrTools === undefined) {
    			await showDialogBookmark(identifier, document.title, [], '', true);
    			return;
    		}
    		const pool = new window.NostrTools.SimplePool();
    		const event10002 = await getReplaceableEvent(pool, indexerRelays, {
    			kinds: [10002],
    			authors: [pubkey],
    			until: now
    		});
    		const rr = getRelaysToUseFromKind10002Event(event10002);
    		const relaysToRead = getRelays(rr, 'read');
    		const relaysToWrite = getRelays(rr, 'write');
    		const kind = 39701;
    		const event39701 = await getReplaceableEvent(pool, relaysToRead, {
    			kinds: [kind],
    			authors: [pubkey],
    			'#d': [identifier],
    			until: now
    		});
    		const tags = event39701?.tags.filter((tag) => !['t', 'title'].includes(tag[0])) ?? [
    			['d', identifier],
    			['published_at', String(now)]
    		];
    		if (document.title.length > 0) {
    			tags.push(['title', document.title]);
    		}
    		const tTagsOld =
    			event39701?.tags.filter((tag) => tag[0] === 't').map((tag) => tag[1].toLowerCase()) ?? [];
    		const resDialog = await showDialogBookmark(
    			identifier,
    			document.title,
    			tTagsOld,
    			event39701?.content ?? '',
    			false
    		);
    		if (resDialog === null) {
    			return;
    		}
    		const { content, tTags } = JSON.parse(resDialog);
    		for (const t of tTags) {
    			tags.push(['t', t]);
    		}
    		const eventTemplate = {
    			content,
    			kind,
    			tags,
    			created_at: now
    		};
    		const signedEvent = await window.nostr.signEvent(eventTemplate);
    		await Promise.any(pool.publish(relaysToWrite, signedEvent));
    		const naddr = window.NostrTools.nip19.naddrEncode({
    			identifier,
    			kind,
    			pubkey,
    			relays: relaysToWrite
    		});
    		const urlToOpen = `${website}${naddr}`;
    		await showDialogResult(relaysToWrite, urlToOpen);
    	};
    
    	affixScriptToHead(nostrToolsUrl, main);
    
    	//https://developer.mozilla.org/ja/docs/Web/API/HTMLScriptElement のコピペ
    	function loadError(oError) {
    		main();
    	}
    
    	function affixScriptToHead(url, onloadFunction) {
    		const newScript = document.createElement('script');
    		newScript.onerror = loadError;
    		if (onloadFunction) {
    			newScript.onload = onloadFunction;
    		}
    		document.head.appendChild(newScript);
    		newScript.src = url;
    	}
    })();
    
  • Permalink
    このページへの個別リンクです。
    RAW
    書かれたコードへの直接のリンクです。
    Packed
    文字列が圧縮された書かれたコードへのリンクです。
    Userscript
    Greasemonkey 等で利用する場合の .user.js へのリンクです。
    Loader
    @require やソースコードが長い場合に多段ロードする Loader コミのコードへのリンクです。
    Metadata
    コード中にコメントで @xxx と書かれたメタデータの JSON です。