This commit is contained in:
Shusui MOYATANI
2023-02-25 23:35:58 +09:00
parent 717c264c2f
commit 57bc321436
27 changed files with 563 additions and 157 deletions

View File

@@ -16,6 +16,5 @@ assignees: syusui-s
**あなたが検討した他の手段について説明してください**
あらゆる他の解決策や検討した機能について、明確かつ簡潔に説明してください。
**Additional context**
Add any other context or screenshots about the feature request here.
**追加の情報**
要望に関する他の情報やスクリーンショットがあればこちらに。

View File

@@ -10,7 +10,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 17
node-version: 19
- name: Get npm cache directory
id: npm-cache-dir
run: |

View File

@@ -8,4 +8,35 @@ A nostr client like TweetDeck powered by SolidJS.
## LICENSE
Copyright (C) 2023 Shusui Moyatani
AGPL-3.0-or-later
### English
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
### 日本語
このプログラムは自由ソフトウェアです。あなたはこれを、Free Software Foundationによって発行された
GNUアフェロー一般公衆利用許諾書バージョン3か、それ以降のいずれかのバージョンが定める条件の下で再頒布または改変することができます。
このプログラムは有用であることを願って頒布されますが、 *全くの無保証* です。
*商業可能性**特定目的への適合性* に対する保証は言外に示されたものも含め、全く存在しません。
詳しくはGNUアフェロー一般公衆利用許諾書をご覧ください。
あなたはこのプログラムと共にGNUアフェロー一般公衆利用許諾書のコピーを一部受け取っているはずです。
もし受け取っていなければ <http://www.gnu.org/licenses/> をご覧ください。
参考日本語訳: <https://gpl.mhatta.org/agpl.ja.html>

198
package-lock.json generated
View File

@@ -12,7 +12,9 @@
"@solidjs/meta": "^0.28.2",
"@solidjs/router": "^0.6.0",
"@tailwindcss/forms": "^0.5.3",
"@tanstack/solid-query": "^4.24.6",
"@tanstack/query-sync-storage-persister": "^4.24.10",
"@tanstack/react-query-persist-client": "^4.24.10",
"@tanstack/solid-query": "^4.24.10",
"@thisbeyond/solid-dnd": "^0.7.3",
"heroicons": "^2.0.15",
"nostr-tools": "^1.3.2",
@@ -1348,20 +1350,86 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "4.24.6",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.24.6.tgz",
"integrity": "sha512-Tfru6YTDTCpX7dKVwHp/sosw/dNjEdzrncduUjIkQxn7n7u+74HT2ZrGtwwrU6Orws4x7zp3FKRqBPWVVhpx9w==",
"version": "4.24.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.24.10.tgz",
"integrity": "sha512-2QywqXEAGBIUoTdgn1lAB4/C8QEqwXHj2jrCLeYTk2xVGtLiPEUD8jcMoeB2noclbiW2mMt4+Fq7fZStuz3wAQ==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/solid-query": {
"version": "4.24.6",
"resolved": "https://registry.npmjs.org/@tanstack/solid-query/-/solid-query-4.24.6.tgz",
"integrity": "sha512-ksUfW4Lwwl85kogQuP46oyqPGBqbSfNMRTu9Ey3FDPjfYzObW4j9opI3TjRoSkOapqVg5KOaobhzu8N2Wp0JBg==",
"node_modules/@tanstack/query-persist-client-core": {
"version": "4.24.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-4.24.10.tgz",
"integrity": "sha512-ObE7k4/TN1EgYMrTCkR43UIvviCtT27QcbE14CgdqeOVRSJ+oiIgXlfir69bcgBUW5Ba7S0ezY2SNV6IfSRNrw==",
"dependencies": {
"@tanstack/query-core": "4.24.6"
"@tanstack/query-core": "4.24.10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/query-sync-storage-persister": {
"version": "4.24.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-4.24.10.tgz",
"integrity": "sha512-bS/vUHmzlnj7FiUZaPEUsEPitiaX2bzPJ9DuqQf7HCNMgqV/pwie90q90XIpGHUw8cF/6e3RnHF346OkH/XehQ==",
"dependencies": {
"@tanstack/query-persist-client-core": "4.24.10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "4.24.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.24.10.tgz",
"integrity": "sha512-FY1DixytOcNNCydPQXLxuKEV7VSST32CAuJ55BjhDNqASnMLZn+6c30yQBMrODjmWMNwzfjMZnq0Vw7C62Fwow==",
"peer": true,
"dependencies": {
"@tanstack/query-core": "4.24.10",
"use-sync-external-store": "^1.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-native": "*"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/@tanstack/react-query-persist-client": {
"version": "4.24.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-4.24.10.tgz",
"integrity": "sha512-Ta8PQua5aJK5F1w1ckX1xFnA4ohNpoeLUvApxtpMb3DKfs1XmyeFaddwyhP7La/EdjTtiInBJ2TmEAjG7EqhCw==",
"dependencies": {
"@tanstack/query-persist-client-core": "4.24.10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"@tanstack/react-query": "4.24.10"
}
},
"node_modules/@tanstack/solid-query": {
"version": "4.24.10",
"resolved": "https://registry.npmjs.org/@tanstack/solid-query/-/solid-query-4.24.10.tgz",
"integrity": "sha512-pwP5vhfcDkwToxRFCXmIr9xFVOGPjanmgrZ5mUge5JE4xAy90lRV4KF36H6QOu0sZ4qwKgh9JcLrVtIcJP1E1g==",
"dependencies": {
"@tanstack/query-core": "4.24.10"
},
"funding": {
"type": "github",
@@ -4884,8 +4952,7 @@
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/js-yaml": {
"version": "4.1.0",
@@ -5423,6 +5490,18 @@
"node": ">=8"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -6905,6 +6984,18 @@
"safe-buffer": "^5.1.0"
}
},
"node_modules/react": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -8028,6 +8119,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
"peer": true,
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -9338,16 +9438,50 @@
}
},
"@tanstack/query-core": {
"version": "4.24.6",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.24.6.tgz",
"integrity": "sha512-Tfru6YTDTCpX7dKVwHp/sosw/dNjEdzrncduUjIkQxn7n7u+74HT2ZrGtwwrU6Orws4x7zp3FKRqBPWVVhpx9w=="
"version": "4.24.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.24.10.tgz",
"integrity": "sha512-2QywqXEAGBIUoTdgn1lAB4/C8QEqwXHj2jrCLeYTk2xVGtLiPEUD8jcMoeB2noclbiW2mMt4+Fq7fZStuz3wAQ=="
},
"@tanstack/query-persist-client-core": {
"version": "4.24.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-persist-client-core/-/query-persist-client-core-4.24.10.tgz",
"integrity": "sha512-ObE7k4/TN1EgYMrTCkR43UIvviCtT27QcbE14CgdqeOVRSJ+oiIgXlfir69bcgBUW5Ba7S0ezY2SNV6IfSRNrw==",
"requires": {
"@tanstack/query-core": "4.24.10"
}
},
"@tanstack/query-sync-storage-persister": {
"version": "4.24.10",
"resolved": "https://registry.npmjs.org/@tanstack/query-sync-storage-persister/-/query-sync-storage-persister-4.24.10.tgz",
"integrity": "sha512-bS/vUHmzlnj7FiUZaPEUsEPitiaX2bzPJ9DuqQf7HCNMgqV/pwie90q90XIpGHUw8cF/6e3RnHF346OkH/XehQ==",
"requires": {
"@tanstack/query-persist-client-core": "4.24.10"
}
},
"@tanstack/react-query": {
"version": "4.24.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.24.10.tgz",
"integrity": "sha512-FY1DixytOcNNCydPQXLxuKEV7VSST32CAuJ55BjhDNqASnMLZn+6c30yQBMrODjmWMNwzfjMZnq0Vw7C62Fwow==",
"peer": true,
"requires": {
"@tanstack/query-core": "4.24.10",
"use-sync-external-store": "^1.2.0"
}
},
"@tanstack/react-query-persist-client": {
"version": "4.24.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-query-persist-client/-/react-query-persist-client-4.24.10.tgz",
"integrity": "sha512-Ta8PQua5aJK5F1w1ckX1xFnA4ohNpoeLUvApxtpMb3DKfs1XmyeFaddwyhP7La/EdjTtiInBJ2TmEAjG7EqhCw==",
"requires": {
"@tanstack/query-persist-client-core": "4.24.10"
}
},
"@tanstack/solid-query": {
"version": "4.24.6",
"resolved": "https://registry.npmjs.org/@tanstack/solid-query/-/solid-query-4.24.6.tgz",
"integrity": "sha512-ksUfW4Lwwl85kogQuP46oyqPGBqbSfNMRTu9Ey3FDPjfYzObW4j9opI3TjRoSkOapqVg5KOaobhzu8N2Wp0JBg==",
"version": "4.24.10",
"resolved": "https://registry.npmjs.org/@tanstack/solid-query/-/solid-query-4.24.10.tgz",
"integrity": "sha512-pwP5vhfcDkwToxRFCXmIr9xFVOGPjanmgrZ5mUge5JE4xAy90lRV4KF36H6QOu0sZ4qwKgh9JcLrVtIcJP1E1g==",
"requires": {
"@tanstack/query-core": "4.24.6"
"@tanstack/query-core": "4.24.10"
}
},
"@thisbeyond/solid-dnd": {
@@ -11877,8 +12011,7 @@
"js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"js-yaml": {
"version": "4.1.0",
@@ -12286,6 +12419,15 @@
}
}
},
"loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"peer": true,
"requires": {
"js-tokens": "^3.0.0 || ^4.0.0"
}
},
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -13367,6 +13509,15 @@
"safe-buffer": "^5.1.0"
}
},
"react": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz",
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
"peer": true,
"requires": {
"loose-envify": "^1.1.0"
}
},
"read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -14217,6 +14368,13 @@
"punycode": "^2.1.0"
}
},
"use-sync-external-store": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
"peer": true,
"requires": {}
},
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -50,7 +50,9 @@
"@solidjs/meta": "^0.28.2",
"@solidjs/router": "^0.6.0",
"@tailwindcss/forms": "^0.5.3",
"@tanstack/solid-query": "^4.24.6",
"@tanstack/query-sync-storage-persister": "^4.24.10",
"@tanstack/react-query-persist-client": "^4.24.10",
"@tanstack/solid-query": "^4.24.10",
"@thisbeyond/solid-dnd": "^0.7.3",
"heroicons": "^2.0.15",
"nostr-tools": "^1.3.2",

View File

@@ -1,19 +1,26 @@
import type { Component } from 'solid-js';
import { Routes, Route } from '@solidjs/router';
import { QueryClient, QueryClientProvider } from '@tanstack/solid-query';
// import { persistQueryClient } from '@tanstack/solid-query-persist-client';
// import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
import Home from '@/pages/Home';
import NotFound from '@/pages/NotFound';
import AccountRecovery from '@/pages/AccountRecovery';
const queryClient = new QueryClient();
const queryClient = new QueryClient({});
// const localStoragePersister = createSyncStoragePersister({ storage: window.localStorage });
// persistQueryClient({
// queryClient,
// persister: localStoragePersister,
// });
const App: Component = () => (
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/recovery" element={<AccountRecovery />} />
<Route path="/*" element={<NotFound />} />
<Route path="/" element={() => <Home />} />
<Route path="/*" element={() => <NotFound />} />
</Routes>
</QueryClientProvider>
);

View File

@@ -60,7 +60,8 @@ const useCachedEvents = (propsProvider: () => UseSubscriptionProps) => {
return getEvents({ pool: pool(), relayUrls, filters, options, signal });
},
{
staleTime: 5 * 60 * 1000, // 5 minutes in ms
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 24 * 60 * 60 * 1000, // 24 hours
},
);
};

View File

@@ -4,6 +4,8 @@ import type { Pub } from 'nostr-tools/relay';
import usePool from '@/clients/usePool';
const currentDate = (): number => Math.floor(Date.now() / 1000);
// NIP-20: Command Result
const waitCommandResult = (pub: Pub): Promise<void> => {
return new Promise((resolve, reject) => {
@@ -48,11 +50,10 @@ const useCommands = () => {
const preSignedEvent: NostrEvent = {
kind: 1,
pubkey,
created_at: Math.floor(Date.now() / 1000),
created_at: currentDate(),
tags: [],
content,
};
// TODO define window.nostr
return publishEvent(relayUrls, preSignedEvent);
},
// NIP-25
@@ -73,14 +74,40 @@ const useCommands = () => {
const preSignedEvent: NostrEvent = {
kind: 7,
pubkey,
created_at: Math.floor(Date.now() / 1000),
created_at: currentDate(),
tags: [
['e', eventId],
['p', notifyPubkey],
],
content,
};
// TODO define window.nostr
return publishEvent(relayUrls, preSignedEvent);
},
// NIP-18
publishDeprecatedRepost({
relayUrls,
pubkey,
eventId,
notifyPubkey,
}: {
relayUrls: string[];
pubkey: string;
eventId: string;
notifyPubkey: string;
}): Promise<Promise<void>[]> {
const preSignedEvent: NostrEvent = {
kind: 6,
pubkey,
created_at: currentDate(),
tags: [
['e', eventId],
['p', notifyPubkey],
],
// Some clients includes some contents here.
// Damus includes an original event. Iris includes #[0] as a mention.
// We just follow the specification.
content: '',
};
return publishEvent(relayUrls, preSignedEvent);
},
};

View File

@@ -1,5 +1,5 @@
import { createSignal } from 'solid-js';
import { SimplePool } from 'nostr-tools/pool';
import { SimplePool } from 'nostr-tools';
const [pool] = createSignal<SimplePool>(new SimplePool());

15
src/clients/usePubkey.ts Normal file
View File

@@ -0,0 +1,15 @@
import { createSignal, onMount, type Accessor } from 'solid-js';
const usePubkey = (): Accessor<string | undefined> => {
const [pubkey, setPubkey] = createSignal<string | undefined>(undefined);
onMount(() => {
if (window.nostr != null) {
window.nostr.getPublicKey().then((pubkey) => setPubkey(pubkey));
}
});
return pubkey;
};
export default usePubkey;

View File

@@ -13,19 +13,24 @@ export type UseSubscriptionProps = {
const sortEvents = (events: NostrEvent[]) =>
Array.from(events).sort((a, b) => b.created_at - a.created_at);
const useSubscription = (propsProvider: () => UseSubscriptionProps) => {
const useSubscription = (propsProvider: () => UseSubscriptionProps | undefined) => {
const pool = usePool();
const [events, setEvents] = createSignal<NostrEvent[]>([]);
createEffect(() => {
const { relayUrls, filters, options } = propsProvider();
const props = propsProvider();
if (props == null) return;
const { relayUrls, filters, options } = props;
const sub = pool().sub(relayUrls, filters, options);
let pushed = false;
let eose = false;
const storedEvents: NostrEvent[] = [];
sub.on('event', (event: NostrEvent) => {
if (!eose) {
pushed = true;
storedEvents.push(event);
} else {
setEvents((prevEvents) => sortEvents([event, ...prevEvents]));
@@ -43,7 +48,10 @@ const useSubscription = (propsProvider: () => UseSubscriptionProps) => {
clearInterval(intervalId);
return;
}
if (pushed) {
pushed = false;
setEvents(sortEvents(storedEvents));
}
}, 100);
onCleanup(() => {

View File

@@ -18,10 +18,6 @@ const DeprecatedRepost: Component<DeprecatedRepostProps> = (props) => {
const pubkey = () => props.event.pubkey;
const eventId = () => props.event.tags.find(([tagName]) => tagName === 'e')?.[1];
if (eventId() == null) {
return 'event not found';
}
const { profile } = useProfile(() => ({ relayUrls: config().relayUrls, pubkey: pubkey() }));
const { event } = useEvent(() => ({ relayUrls: config().relayUrls, eventId: eventId() }));
@@ -33,7 +29,7 @@ const DeprecatedRepost: Component<DeprecatedRepostProps> = (props) => {
</div>
<div>{profile()?.display_name} Reposted</div>
</div>
<Show when={event() != null}>
<Show when={event() != null} fallback={'loading'}>
<TextNote event={event()} />
</Show>
</div>

View File

@@ -1,4 +1,4 @@
import { createSignal, type Component, type JSX } from 'solid-js';
import { createSignal, createMemo, type Component, type JSX } from 'solid-js';
import PaperAirplane from 'heroicons/24/solid/paper-airplane.svg';
type NotePostFormProps = {
@@ -8,11 +8,18 @@ type NotePostFormProps = {
const NotePostForm: Component<NotePostFormProps> = (props) => {
const [text, setText] = createSignal<string>('');
const handleChangeText: JSX.EventHandler<HTMLTextAreaElement, Event> = (ev) => {
setText(ev.currentTarget.value);
};
const handleSubmit: JSX.EventHandler<HTMLFormElement, Event> = (ev) => {
ev.preventDefault();
props.onPost({ content: text() });
// TODO 投稿完了したらなんかする
};
const submitDisabled = createMemo(() => text().trim().length === 0);
return (
<div class="p-1">
<form class="grid w-64 gap-1" onSubmit={handleSubmit}>
@@ -21,13 +28,16 @@ const NotePostForm: Component<NotePostFormProps> = (props) => {
class="rounded border-none"
rows={4}
placeholder="いまどうしてる?"
onChange={(ev) => {
setText(ev.target.value);
}}
onInput={handleChangeText}
value={text()}
/>
<div class="grid justify-end">
<button class="h-8 w-8 rounded bg-primary p-2 font-bold text-white" type="submit">
<button
class="h-8 w-8 rounded bg-primary p-2 font-bold text-white"
classList={{ 'bg-primary-disabled': submitDisabled(), 'bg-primary': !submitDisabled() }}
type="submit"
disabled={submitDisabled()}
>
<PaperAirplane />
</button>
</div>

View File

@@ -0,0 +1,28 @@
import { For, Switch, Match, type Component } from 'solid-js';
import { Kind, type Event as NostrEvent } from 'nostr-tools/event';
import TextNote from '@/components/TextNote';
import Reaction from '@/components/notification/Reaction';
export type TimelineProps = {
events: NostrEvent[];
};
const Timeline: Component<TimelineProps> = (props) => {
return (
<For each={props.events}>
{(event) => (
<Switch fallback={<div>unknown event</div>}>
<Match when={event.kind === Kind.Text}>
<TextNote event={event} />
</Match>
<Match when={event.kind === Kind.Reaction}>
<Reaction event={event} />
</Match>
</Switch>
)}
</For>
);
};
export default Timeline;

View File

@@ -1,12 +1,23 @@
import { Show, For, createSignal, createMemo, onMount, onCleanup } from 'solid-js';
import type { Component } from 'solid-js';
import {
Show,
For,
createSignal,
createMemo,
onMount,
onCleanup,
type JSX,
Component,
} from 'solid-js';
import type { Event as NostrEvent } from 'nostr-tools/event';
import HeartOutlined from 'heroicons/24/outline/heart.svg';
import ArrowPathRoundedSquare from 'heroicons/24/outline/arrow-path-rounded-square.svg';
import ChatBubbleLeft from 'heroicons/24/outline/chat-bubble-left.svg';
import EllipsisHorizontal from 'heroicons/24/outline/ellipsis-horizontal.svg';
import useProfile from '@/clients/useProfile';
import useConfig from '@/clients/useConfig';
import useCommands from '@/clients/useCommands';
import useDatePulser from '@/hooks/useDatePulser';
import { formatRelative } from '@/utils/formatDate';
import ColumnItem from '@/components/ColumnItem';
@@ -20,21 +31,37 @@ export type TextNoteProps = {
const TextNote: Component<TextNoteProps> = (props) => {
const currentDate = useDatePulser();
const [config] = useConfig();
const commands = useCommands();
const { profile: author } = useProfile(() => ({
relayUrls: config().relayUrls,
pubkey: props.event.pubkey,
}));
const replyingToPubKeys = createMemo(() =>
props.event.tags.filter((tag) => tag[0] === 'p').map((e) => e[1]),
);
// TODO 日付をいい感じにフォーマットする関数を作る
const createdAt = () => formatRelative(new Date(props.event.created_at * 1000), currentDate());
const handleRepost: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
ev.preventDefault();
commands.publishDeprecatedRepost({});
};
const handleReaction: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (ev) => {
ev.preventDefault();
commands.publishReaction({
relayUrls: config().relayUrls,
pubkey: pubkeyHex,
eventId: props.event.id,
});
};
return (
<div class="textnote">
<ColumnItem>
<div class="author-icon max-w-10 max-h-10 shrink-0">
<Show when={author()?.picture} fallback={<div class="h-10 w-10" />}>
<div class="author-icon h-10 w-10 shrink-0">
<Show when={author()?.picture}>
<img
src={author()?.picture}
alt="icon"
@@ -47,7 +74,7 @@ const TextNote: Component<TextNoteProps> = (props) => {
<div class="flex justify-between gap-1 text-xs">
<div class="author flex min-w-0 truncate">
{/* TODO link to author */}
<Show when={author()?.display_name}>
<Show when={author()?.display_name != null && author()?.display_name.length > 0}>
<div class="author-name pr-1 font-bold">{author()?.display_name}</div>
</Show>
<div class="author-username truncate text-zinc-600">
@@ -71,15 +98,21 @@ const TextNote: Component<TextNoteProps> = (props) => {
</div>
</Show>
<div class="content whitespace-pre-wrap break-all">
<TextNoteContentDisplay event={props.event} />
<TextNoteContentDisplay event={props.event} embedding={true} />
</div>
<div class="flex justify-evenly">
<div class="flex justify-end gap-16">
<button class="h-4 w-4 text-zinc-400">
<ChatBubbleLeft />
</button>
<button class="h-4 w-4 text-zinc-400" onClick={handleRepost}>
<ArrowPathRoundedSquare />
</button>
<button class="h-4 w-4 text-zinc-400">
<button class="h-4 w-4 text-zinc-400" onClick={handleReaction}>
<HeartOutlined />
</button>
<button class="h-4 w-4 text-zinc-400">
<EllipsisHorizontal />
</button>
</div>
</div>
</ColumnItem>

View File

@@ -1,5 +1,5 @@
import { For, Switch, Match, type Component } from 'solid-js';
import type { Event as NostrEvent } from 'nostr-tools/event';
import { Kind, type Event as NostrEvent } from 'nostr-tools/event';
import TextNote from '@/components/TextNote';
import DeprecatedRepost from '@/components/DeprecatedRepost';
@@ -8,15 +8,15 @@ export type TimelineProps = {
events: NostrEvent[];
};
export const Timeline: Component<TimelineProps> = (props) => {
const Timeline: Component<TimelineProps> = (props) => {
return (
<For each={props.events}>
{(event) => (
<Switch fallback={<div>unknown event</div>}>
<Match when={event.kind === 1}>
<Match when={event.kind === Kind.Text}>
<TextNote event={event} />
</Match>
<Match when={event.kind === 6}>
<Match when={(event.kind as number) === 6}>
<DeprecatedRepost event={event} />
</Match>
</Switch>

View File

@@ -0,0 +1,50 @@
import { Switch, Match, type Component, Show } from 'solid-js';
import { type Event as NostrEvent } from 'nostr-tools/event';
import HeartSolid from 'heroicons/24/solid/heart.svg';
import useConfig from '@/clients/useConfig';
import useProfile from '@/clients/useProfile';
import useEvent from '@/clients/useEvent';
import TextNote from '../TextNote';
type ReactionProps = {
event: NostrEvent;
};
const Reaction: Component<ReactionProps> = (props) => {
const [config] = useConfig();
const eventId = () => props.event.tags.find(([tagName]) => tagName === 'e')?.[1];
const { profile } = useProfile(() => ({
relayUrls: config().relayUrls,
pubkey: props.event.pubkey,
}));
const { event } = useEvent(() => ({ relayUrls: config().relayUrls, eventId: eventId() }));
return (
<div>
<div class="flex gap-1 text-sm">
<div>
<Switch fallback={props.event.content}>
<Match when={props.event.content === '+'}>
<span class="inline-block h-4 w-4 text-rose-400">
<HeartSolid />
</span>
</Match>
</Switch>
</div>
<div>
<span class="font-bold">{profile()?.display_name}</span>
{' reacted'}
</div>
</div>
<div>
<Show when={event() != null} fallback={'loading'}>
<TextNote event={event()} />
</Show>
</div>
</div>
);
};
export default Reaction;

View File

@@ -5,7 +5,7 @@ export type MentionedEventDisplayProps = {
};
const MentionedEventDisplay = (props: MentionedEventDisplayProps) => {
return <span class="text-blue-500 underline">@{props.mentionedEvent.eventId}</span>;
return <span class="text-blue-500 underline">#{props.mentionedEvent.eventId}</span>;
};
export default MentionedEventDisplay;

View File

@@ -7,6 +7,7 @@ import MentionedEventDisplay from '@/components/textNote/MentionedEventDisplay';
export type TextNoteContentDisplayProps = {
event: NostrEvent;
embedding: boolean;
};
const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
@@ -19,7 +20,7 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
if (item.type === 'MentionedUser') {
return <MentionedUserDisplay mentionedUser={item} />;
}
if (item.type === 'MentionedEvent') {
if (item.type === 'MentionedEvent' && props.embedding) {
return <MentionedEventDisplay mentionedEvent={item} />;
}
if (item.type === 'HashTag') {

57
src/core/event.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { Event as NostrEvent } from 'nostr-tools/event';
type EventMarker = 'reply' | 'root' | 'mention';
type TaggedEvent = {
id: string;
relayUrl?: string;
marker: EventMarker;
};
const eventWrapper = (event: NostrEvent) => {
return {
/**
* "replyingTo"
*/
taggedUsers(): string[] {
const pubkeys = new Set<string>();
event.tags.forEach(([tagName, pubkey]) => {
if (tagName === 'p') {
pubkeys.add(pubkey);
}
});
return Array.from(pubkeys);
},
taggedEvents(): TaggedEvent[] {
const events = event.tags.filter(([tagName]) => tagName === 'e');
const positionToMarker = (index: number): EventMarker => {
// One "e" tag
if (events.length === 1) return 'reply';
// Two "e" tags or many "e" tags : first tag is root
if (index === 0) return 'root';
// Two "e" tags
if (events.length === 2) return 'reply';
// Many "e" tags
// Last one is reply.
if (index === events.length - 1) return 'reply';
// other ones are mentions.
return 'mention';
};
return events.map(([, eventId, relayUrl, marker], index) => ({
id: eventId,
relayUrl,
marker: (marker as EventMarker) ?? positionToMarker(index),
}));
},
replyingToEvent(): TaggedEvent | undefined {
return this.taggedEvents().find(({ marker }) => marker === 'reply');
},
rootEvent(): TaggedEvent | undefined {
return this.taggedEvents().find(({ marker }) => marker === 'root');
},
mentionedEvents(): TaggedEvent[] {
return this.taggedEvents().filter(({ marker }) => marker === 'mention');
},
};
};

View File

@@ -13,10 +13,18 @@ export type MessageChannelRequest<T> = {
message: T;
};
type Primitives = number | string | null;
type Serializable = Record<string, Primitives | Array<Primitives>>;
// https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
type Clonable =
| number
| string
| boolean
| null
| bigint
| Date
| Array<Clonable>
| Record<string, Clonable>;
const useMessageChannel = <T extends Serializable>(propsProvider: () => UseMessageChannelProps) => {
const useMessageChannel = <T extends Clonable>(propsProvider: () => UseMessageChannelProps) => {
const channel = () => channels()[propsProvider().id];
onMount(() => {
@@ -29,7 +37,7 @@ const useMessageChannel = <T extends Serializable>(propsProvider: () => UseMessa
}
});
const listen = async (requestId: string, timeout = 1000): Promise<T> => {
const listen = async (requestId: string, timeoutMs = 1000): Promise<T> => {
return new Promise((resolve, reject) => {
const listener = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
@@ -44,11 +52,12 @@ const useMessageChannel = <T extends Serializable>(propsProvider: () => UseMessa
};
setTimeout(() => {
reject(new Error('Timeout'));
channel().port2.removeEventListener('message', listener);
}, timeout);
reject(new Error('TimeoutError'));
}, timeoutMs);
window.addEventListener('message', listener, false);
channel().port2.addEventListener('message', listener, false);
channel().port2.start();
});
};
@@ -56,7 +65,7 @@ const useMessageChannel = <T extends Serializable>(propsProvider: () => UseMessa
async requst(message: T) {
const requestId = Math.random().toString();
const messageStr = JSON.stringify({ message, requestId });
const response = listen(requestId, timeout);
const response = listen(requestId, timeoutMs);
channel().postMessage(messageStr);
return response;
},

View File

@@ -1,85 +0,0 @@
import type { Component } from 'solid-js';
import { getEventHash, relayInit } from 'nostr-tools';
const relays = [
'wss://brb.io',
'wss://nostr.h3z.jp',
'wss://relay.damus.io',
'wss://relay.snort.social',
'wss://relay.nostr.wirednet.jp',
'wss://relay-jp.nostr.wirednet.jp',
'wss://nos.lol',
'wss://eden.nostr.land',
'wss://nostr-pub.wellorder.net',
'wss://nostr.bitcoiner.social',
'wss://offchain.pub',
'wss://relay.current.fyi',
'wss://nostr.relayer.se',
'wss://relay.realsearch.cc',
'wss://jiggytom.ddns.net',
// 'wss://nostr.fly.dev',
// 'wss://nostr-relay.untethr.me',
];
/*
*{
"event": {
"kind": 0,
"content": "{\"name\":\"syusui_s\",\"about\":\"多分復活\",\"picture\":\"https://i.gyazo.com/883119a7763e594d30c5706a62969d52.jpg\",\"display_name\":\"しゅうすい\",\"nip05\":\"_@syusui-s.github.io\"}",
"tags": [],
"created_at": 1676255623,
"pubkey": "96203d66276e3214ea93b6c78a577c3c9a7279f9ee7e51b22f3b8c17643a819c",
"id": "8776d66d9de4c59abddb0eb83214247edd68b5bc61fa3657b134cf892f8f7610"
}
}
*/
const recover = async () => {
const event = {
kind: 0,
content:
'{"name":"syusui_s","about":"","display_name":"しゅうすい","picture":"https://i.gyazo.com/883119a7763e594d30c5706a62969d52.jpg","nip05":"_@syusui-s.github.io"}',
tags: [],
created_at: Math.floor(new Date() / 1000),
pubkey: '96203d66276e3214ea93b6c78a577c3c9a7279f9ee7e51b22f3b8c17643a819c',
};
event.id = getEventHash(event);
const signedEvent = await window.nostr.signEvent(event);
console.log(signedEvent);
for (const url of relays) {
console.log(url);
const relay = relayInit(url);
await relay.connect();
const pub = relay.publish(signedEvent);
pub.on('ok', () => {
console.log(`${url} has accepted our event`);
});
pub.on('seen', () => {
console.log(`we saw the event on ${url}`);
});
pub.on('failed', (reason) => {
console.log(`failed to publish to ${url}: ${reason}`);
});
}
console.log('done');
};
const AccountRecovery: Component = () => {
const handleClick = () => {
recover();
};
return (
<div>
<button onClick={handleClick}></button>
</div>
);
};
export default AccountRecovery;

View File

@@ -5,6 +5,7 @@ import Column from '@/components/Column';
import NotePostForm from '@/components/NotePostForm';
import SideBar from '@/components/SideBar';
import Timeline from '@/components/Timeline';
import Notification from '@/components/Notification';
import TextNote from '@/components/TextNote';
import useCommands from '@/clients/useCommands';
import useConfig from '@/clients/useConfig';
@@ -62,6 +63,29 @@ const Home: Component = () => {
],
}));
const { events: notifications } = useSubscription(() => ({
relayUrls: config().relayUrls,
filters: [
{
kinds: [1, 6, 7],
'#p': [pubkeyHex],
limit: 25,
since: Math.floor(Date.now() / 1000) - 12 * 60 * 60,
},
],
}));
const { events: localTimeline } = useSubscription(() => ({
relayUrls: ['wss://relay-jp.nostr.wirednet.jp', 'wss://nostr.h3z.jp/'],
filters: [
{
kinds: [1, 6],
limit: 25,
since: Math.floor(Date.now() / 1000) - 12 * 60 * 60,
},
],
}));
const { events: searchPosts } = useSubscription(() => ({
relayUrls: ['wss://relay.nostr.band/'],
filters: [
@@ -108,6 +132,12 @@ const Home: Component = () => {
/>
<Timeline events={followingsPosts()} />
</Column>
<Column name="通知" width="medium">
<Notification events={notifications()} />
</Column>
<Column name="ローカル" width="medium">
<Timeline events={localTimeline()} />
</Column>
<Column name="自分の投稿" width="medium">
<Timeline events={myPosts()} />
</Column>

View File

@@ -8,6 +8,7 @@ module.exports = {
colors: {
// a color for primary actions like a submit button.
primary: colors.rose['300'],
'primary-disabled': colors.rose['200'],
'sidebar-bg': colors.rose['100'],
},
},

View File

@@ -19,7 +19,8 @@
"isolatedModules": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
"@/*": ["src/*"],
"*": ["types/*"]
},
"incremental": true
},

27
types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,27 @@
// The original code was published under the public domain license (CC0-1.0).
// https://gist.github.com/syusui-s/cd5482ddfc83792b54a756759acbda55
import { type Event as NostrEvent } from 'nostr-tools/event';
type NostrAPI = {
/** returns a public key as hex */
getPublicKey(): Promise<string>;
/** takes an event object, adds `id`, `pubkey` and `sig` and returns it */
signEvent(event: Event): Promise<NostrEvent>;
// Optional
/** returns a basic map of relay urls to relay policies */
getRelays?(): Promise<{ [url: string]: { read: boolean; write: boolean } }>;
/** NIP-04: Encrypted Direct Messages */
nip04: {
/** returns ciphertext and iv as specified in nip-04 */
encrypt(pubkey: string, plaintext: string): Promise<string>;
/** takes ciphertext and iv as specified in nip-04 */
decrypt(pubkey: string, ciphertext: string): Promise<string>;
};
};
interface Window {
nostr?: NostrAPI;
}