/*
* @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, '&')
.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, 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 indexOfFirstUnmatchingCloseParen = (url, left, right) => {
let nest = 0;
for (let i = 0; i < url.length; i++) {
const c = url.charAt(i);
if (c === left) {
nest++;
} else if (c === right) {
if (nest <= 0) {
return i;
}
nest--;
}
}
return -1;
};
//https://github.com/jiftechnify/motherfucking-nostr-client
const urlLinkString = (url) => {
for (const [left, right] of [
['(', ')'],
['[', ']']
]) {
const splitIdx = indexOfFirstUnmatchingCloseParen(url, left, right);
if (splitIdx >= 0) {
return [url.substring(0, splitIdx), url.substring(splitIdx)];
}
}
return [url, ''];
};
const getTagsForContent = (content) => {
const tags = [];
const ppMap = new Map();
const epMap = new Map();
const apMap = new Map();
const matchesIteratorId = content.matchAll(
/(^|\W|\b)(nostr:(note1\w{58}|nevent1\w+|naddr1\w+))($|\W|\b)/g
);
for (const match of matchesIteratorId) {
let d;
try {
d = window.NostrTools.nip19.decode(match[3]);
} catch (error) {
continue;
}
if (d.type === 'note') {
epMap.set(d.data, { id: d.data });
} else if (d.type === 'nevent') {
epMap.set(d.data.id, d.data);
if (d.data.author !== undefined) {
ppMap.set(d.data.author, { pubkey: d.data.author });
}
} else if (d.type === 'naddr') {
apMap.set(`${d.data.kind}:${d.data.pubkey}:${d.data.identifier}`, d.data);
ppMap.set(d.data.pubkey, { pubkey: d.data.pubkey });
}
}
const matchesIteratorPubkey = content.matchAll(
/(^|\W|\b)(nostr:(npub1\w{58}|nprofile1\w+))($|\W|\b)/g
);
for (const match of matchesIteratorPubkey) {
let d;
try {
d = window.NostrTools.nip19.decode(match[3]);
} catch (error) {
continue;
}
if (d.type === 'npub') {
ppMap.set(d.data, { pubkey: d.data });
} else if (d.type === 'nprofile') {
ppMap.set(d.data.pubkey, d.data);
}
}
const matchesIteratorLink = content.matchAll(/https?:\/\/[\w!?/=+\-_~:;.,*&@#$%()[\]]+/g);
const links = new Set();
for (const match of matchesIteratorLink) {
links.add(urlLinkString(match[0])[0]);
}
for (const [id, ep] of epMap) {
const qTag = ['q', id];
const recommendedRelayForQuote = ep.relays
?.filter((relay) => relay.startsWith('wss://'))
.at(0);
if (recommendedRelayForQuote !== undefined) {
qTag.push(recommendedRelayForQuote);
}
tags.push(qTag);
}
for (const [a, ap] of apMap) {
const qTag = ['q', a];
const recommendedRelayForQuote = ap.relays
?.filter((relay) => relay.startsWith('wss://'))
.at(0);
if (recommendedRelayForQuote !== undefined) {
qTag.push(recommendedRelayForQuote);
}
tags.push(qTag);
ppMap.set(ap.pubkey, { pubkey: ap.pubkey });
}
for (const [p, pp] of ppMap) {
const pTag = ['p', p];
const recommendedRelayForPubkey = pp.relays
?.filter((relay) => relay.startsWith('wss://'))
.at(0);
if (recommendedRelayForPubkey !== undefined) {
pTag.push(recommendedRelayForPubkey);
}
tags.push(pTag);
}
for (const r of links) {
tags.push(['r', r]);
}
return tags;
};
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 relaysToWrite = getRelays(rr, 'write');
const kind = 39701;
const event39701 = await getReplaceableEvent(pool, relaysToWrite, {
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]);
}
for (const tag of getTagsForContent(content)) {
tags.push(tag);
}
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;
}
})();