nostr bookmark

    @@ -75,6 +75,148 @@ ); }; + const showDialog = (identifier, title, tTags, content) => { + 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 view = escapeHTML` + <dialog id="nostr-web-bookmark-trend-dialog"> + <form> + <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 * { + all: revert; + } + dialog#nostr-web-bookmark-trend-dialog { + 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'; + 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}`); + + 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 main = async () => { const pubkey = await getPubkey(); if (pubkey === undefined) { @@ -103,17 +245,28 @@ '#d': [identifier], until: now }); - const content = prompt('コメントを入力してください。', event39701?.content); - if (content === null) { - return; - } - const tags = event39701?.tags.filter((tag) => tag[0] !== 'title') ?? [ + 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 showDialog( + identifier, + document.title, + tTagsOld, + event39701?.content ?? '' + ); + if (resDialog === null) { + return; + } + const { content, tTags } = JSON.parse(resDialog); + for (const t of tTags) { + tags.push(['t', t]); + } const eventTemplate = { content, kind,
  • /*
     * @title nostr bookmark
     * @description 現在のWebページをNostrでブックマーク
     * @include http://*
     * @license CC0 1.0
     * @require
     */
    
    (() => {
    	const nostrToolsUrl = 'https://unpkg.com/nostr-tools/lib/nostr.bundle.js';
    	const profileRelay = 'wss://directory.yabu.me/';
    	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 showDialog = (identifier, title, tTags, content) => {
    		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 view = escapeHTML`
    		<dialog id="nostr-web-bookmark-trend-dialog">
    			<form>
    				<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 * {
    				all: revert;
    			}
    			dialog#nostr-web-bookmark-trend-dialog {
    				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';
    		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}`);
    
    		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 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?:\/\//, '');
    		const pool = new window.NostrTools.SimplePool();
    		const event10002 = await getReplaceableEvent(pool, [profileRelay], {
    			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 showDialog(
    			identifier,
    			document.title,
    			tTagsOld,
    			event39701?.content ?? ''
    		);
    		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);
    		if (
    			!window.confirm(`以下のリレーに送信します。よろしいですか?\n${relaysToWrite.join('\n')}`)
    		) {
    			return;
    		}
    		await Promise.any(pool.publish(relaysToWrite, signedEvent));
    		if (!window.confirm('送信完了しました。ブックマークページを開きますか?')) {
    			return;
    		}
    		const naddr = window.NostrTools.nip19.naddrEncode({
    			identifier,
    			kind,
    			pubkey,
    			relays: relaysToWrite
    		});
    		const urlToOpen = `https://nostr-web-bookmark-trend.vercel.app/${naddr}`;
    		location.href = urlToOpen;
    	};
    
    	affixScriptToHead(nostrToolsUrl, main);
    
    	//https://developer.mozilla.org/ja/docs/Web/API/HTMLScriptElement のコピペ
    	function loadError(oError) {
    		throw new URIError(`スクリプト ${oError.target.src} は正しく読み込まれませんでした。`);
    	}
    
    	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 です。