nostr bookmark
by
Nikola
06/06 [2025/06/06 15:28:55]
現在のWebページをNostrでブックマーク
@@ -8,7 +8,11 @@
(() => {
const nostrToolsUrl = 'https://unpkg.com/nostr-tools/lib/nostr.bundle.js';
- const profileRelay = 'wss://directory.yabu.me/';
+ const indexerRelays = [
+ 'wss://directory.yabu.me/',
+ 'wss://purplepag.es/',
+ 'wss://indexer.coracle.social/'
+ ];
const now = Math.floor(Date.now() / 1000);
const getPubkey = async () => {
@@ -274,7 +278,7 @@
.replace(/\?$/, '')
.replace(/^https?:\/\//, '');
const pool = new window.NostrTools.SimplePool();
- const event10002 = await getReplaceableEvent(pool, [profileRelay], {
+ const event10002 = await getReplaceableEvent(pool, indexerRelays, {
kinds: [10002],
authors: [pubkey],
until: now
/*
* @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 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
};
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) => {
const view = escapeHTML`
<dialog id="nostr-web-bookmark-trend-dialog-bookmark">
<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-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}`);
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?:\/\//, '');
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 ?? ''
);
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 = `https://nostr-web-bookmark-trend.vercel.app/${naddr}`;
await showDialogResult(relaysToWrite, 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 です。