Welcome to Eve
- Your Private Community Network
+ Your Closed Community Network
Connect, share, and engage with your community in a secure,
members-only space designed just for you.
@@ -296,11 +332,7 @@ export class InitialSetup extends LitElement {
private renderRelaySetupPage() {
return html`
-
-
+
Configure Eve Relay
@@ -321,7 +353,8 @@ export class InitialSetup extends LitElement {
${when(
this.relayStatus.running,
- () => html`
+ () =>
+ html`
Relay is running with PID: ${this.relayStatus.pid}
@@ -347,7 +380,7 @@ export class InitialSetup extends LitElement {
@click=${() => this.nextStep()}
variant="primary"
?disabled=${!this.relayStatus.running}
- label=${this.selectedCCN ? 'Continue' : 'Create CCN'}
+ label=${this.selectedCCN ? 'Continue' : 'New CCN'}
>
@@ -382,59 +415,116 @@ export class InitialSetup extends LitElement {
private async updateRelayStatus() {
this.relayStatus = await window.relay.getStatus();
- if (this.relayStatus.running) setTimeout(() => this.updateRelayStatus(), 2000);
+ if (this.relayStatus.running) {
+ setTimeout(() => this.updateRelayStatus(), 2000);
+ }
}
private async createCCN() {
- await new Promise((resolve) => {
- const ws = new WebSocket('ws://localhost:6942');
- ws.onopen = () => {
- ws.send(
- JSON.stringify([
- 'CCN',
- 'CREATE',
+ this.loading = true;
+ if (this.joinCCN) {
+ this.loadingMessage = 'Joining community... Redeeming invite...';
+ const { npub, invite } = readInvite(this.inviteCode as `${string}1${string}`);
+ const dm = await generateDM(npub, invite, [['type', 'invite']]);
+ await dm.publish(unsafeRelaySet, 10000, 2);
+ this.loadingMessage = 'Waiting for community to let you in...';
+ const ccnPubkeyHex = nip19.decode<'npub'>(npub).data;
+ const ownPubkeyHex = nostrTools.getPublicKey(this.randomPrivateKey);
+ // eveinvite1mm7p5m83dawq96qh58f6r7gl3fvvlp8mvp68a8ksq6ycga5r6wrmcur22dtjatpq4cy00rut2e6y563nnw8teh5xqppggzyq92gyywqstsk60
+ const { privateKey, existingMembers, ccnName } = await new Promise<{
+ privateKey: string;
+ existingMembers: string[];
+ ccnName: string;
+ }>((resolve) => {
+ ndk
+ .subscribe(
{
- name: this.communityName,
- seed: this.seedPhrase,
+ kinds: [NDKKind.GiftWrap],
+ '#p': [ownPubkeyHex],
},
- ]),
- );
- };
- ws.onmessage = ({ data }) => {
- const responseData = JSON.parse(data);
- if (responseData[0] !== 'OK' || responseData[1] !== 'CCN CREATED' || responseData[2] !== true) return;
- resolve(true);
- ws.close();
- this.selectedCCN = JSON.parse(responseData[3]).pubkey;
- };
- ws.onerror = () => {
- resolve(false);
- };
- });
+ {},
+ unsafeRelaySet,
+ true,
+ )
+ .on('event', async (event) => {
+ resolve(await decryptInviteResponseDM(event));
+ });
+ });
+ this.loadingMessage = `You've been invited to ${ccnName}. Joining ${existingMembers.length} members...`;
+ await new Promise((resolve) => {
+ const ws = new WebSocket('ws://localhost:6942');
+ ws.onopen = () => {
+ ws.send(
+ JSON.stringify([
+ 'CCN',
+ 'ADD',
+ {
+ name: ccnName,
+ allowedPubkeys: existingMembers,
+ privateKey,
+ },
+ ]),
+ );
+ };
+ ws.onmessage = ({ data }) => {
+ const responseData = JSON.parse(data);
+ if (responseData[0] !== 'OK' || responseData[1] !== 'CCN ADDED' || responseData[2] !== true) return;
+ const responseJson = JSON.parse(responseData[3]);
+ if (responseJson.pubkey !== ccnPubkeyHex) return;
+ resolve(true);
+ ws.close();
+ this.selectedCCN = ccnPubkeyHex;
+ };
+ });
+ } else {
+ this.loadingMessage = 'Creating community...';
+ await new Promise((resolve) => {
+ const ws = new WebSocket('ws://localhost:6942');
+ ws.onopen = () => {
+ ws.send(
+ JSON.stringify([
+ 'CCN',
+ 'CREATE',
+ {
+ name: this.communityName,
+ seed: this.seedPhrase,
+ creator: nostrTools.getPublicKey(this.randomPrivateKey),
+ },
+ ]),
+ );
+ };
+ ws.onmessage = ({ data }) => {
+ const responseData = JSON.parse(data);
+ if (responseData[0] !== 'OK' || responseData[1] !== 'CCN CREATED' || responseData[2] !== true) return;
+ resolve(true);
+ ws.close();
+ this.selectedCCN = JSON.parse(responseData[3]).pubkey;
+ };
+ ws.onerror = () => {
+ resolve(false);
+ };
+ });
+ }
this.handleNavigation(4);
+ this.loading = false;
+ }
+
+ private renderJoinCCNPage() {
+ return html`
+
+
Joining a Community
+
+ If you'd like to join an existing community, please enter your invite code below.
+
+
+
+ `;
}
private renderSeedPhrasePage() {
return html`
-
-
-
- Getting Started
- Creating a Community
-
- Connect with others by joining an existing community or creating
- your own.
-
-
- During this alpha phase, community setup requires a few manual
- steps. We're actively working to streamline this process in future
- updates.
-
-
-
+
+
Creating a Community
Seed Phrase
@@ -450,23 +540,8 @@ export class InitialSetup extends LitElement {
Security) to enable simpler invitation-based community access, as
well as improve your community's security.
-
+
-
Community Name
@@ -482,6 +557,47 @@ export class InitialSetup extends LitElement {
@change=${this.onCommunityNameInput}
>
+
+ `;
+ }
+
+ private isValidInviteCode() {
+ if (!this.inviteCode?.startsWith('eveinvite1')) return false;
+ try {
+ const invite = readInvite(this.inviteCode as `${string}1${string}`);
+ return invite.npub && invite.invite;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ private renderCCNSetupPage() {
+ return html`
+
+
+ Getting Started
+
+ Connect with others by joining an existing community or creating
+ your own.
+
+
+ Do you have an invite code, or would you like to create a new
+ community?
+
+
+
{
+ this.joinCCN = false;
+ }}>
+
{
+ this.joinCCN = true;
+ }}>
+
+ ${when(
+ this.joinCCN,
+ () => this.renderJoinCCNPage(),
+ () => this.renderSeedPhrasePage(),
+ )}
+
this.createCCN()}
- ?disabled=${!this.isValidSeedPhrase() || !this.isValidCommunityName()}
- label="Continue"
+ ?disabled=${this.joinCCN ? !this.isValidInviteCode() : !this.isValidSeedPhrase() || !this.isValidCommunityName()}
+ label=${this.joinCCN ? 'Join CCN' : 'Create CCN'}
variant="primary"
>
@@ -512,11 +628,7 @@ export class InitialSetup extends LitElement {
private renderProfileSetupPage() {
return html`
-
-
+
Complete Your Profile
Great progress! Let's set up your community profile.
@@ -590,12 +702,9 @@ export class InitialSetup extends LitElement {
resolve(false);
};
});
- const randomPrivateKey = nostrTools.generateSecretKey();
- const encryptedNsec = nip49.encrypt(randomPrivateKey, this.encryptionPassphrase);
- const npub = nip19.npubEncode(nostrTools.getPublicKey(randomPrivateKey));
+ const encryptedNsec = nip49.encrypt(this.randomPrivateKey, this.encryptionPassphrase);
+ const npub = nip19.npubEncode(nostrTools.getPublicKey(this.randomPrivateKey));
this.lightningAddress = `${npub}@npub.cash`;
- setSigner(new NDKPrivateKeySigner(randomPrivateKey));
- await ndk.connect(10000);
const event = new NDKEvent(ndk);
event.kind = NDKKind.Metadata;
event.content = JSON.stringify({
@@ -612,11 +721,7 @@ export class InitialSetup extends LitElement {
private renderFinalPage() {
return html`
-
-
+
Done!
@@ -651,6 +756,7 @@ export class InitialSetup extends LitElement {
}
finish() {
+ localStorage.setItem('selectedCCN', JSON.stringify({ date: Date.now(), pubkey: this.selectedCCN }));
this.dispatchEvent(
new CustomEvent('finish', {
detail: {
@@ -664,14 +770,14 @@ export class InitialSetup extends LitElement {
);
}
- override render() {
+ private renderCurrentPage() {
switch (this.currentPage) {
case 1:
return this.renderWelcomePage();
case 2:
return this.renderRelaySetupPage();
case 3:
- return this.renderSeedPhrasePage();
+ return this.renderCCNSetupPage();
case 4:
return this.renderProfileSetupPage();
case 5:
@@ -682,4 +788,23 @@ export class InitialSetup extends LitElement {
`;
}
}
+
+ override render() {
+ if (this.loading) {
+ return html`
+
+
${this.loadingMessage}
+
`;
+ }
+ return html`
+
+
+
+ ${this.renderCurrentPage()}
+
+ `;
+ }
}
diff --git a/src/components/InviteCodeGenerator.ts b/src/components/InviteCodeGenerator.ts
new file mode 100644
index 0000000..b1b38b6
--- /dev/null
+++ b/src/components/InviteCodeGenerator.ts
@@ -0,0 +1,104 @@
+import { inviteKind, ndk } from '@/ndk';
+import { getActiveCCNNpub, getCCNName } from '@/utils/getCCNList';
+import { randomBytes } from '@noble/ciphers/webcrypto';
+import { NDKEvent } from '@nostr-dev-kit/ndk';
+import { sha512Hash } from '@utils/sha512';
+import { LitElement, css, html } from 'lit';
+import { customElement, state } from 'lit/decorators.js';
+import { ifDefined } from 'lit/directives/if-defined.js';
+import { when } from 'lit/directives/when.js';
+
+import { generateInvite } from '@/utils/invite';
+import '@components/CCNInvitation';
+import '@components/General/Button';
+import '@components/MarkdownContent';
+import { bytesToHex } from '@noble/ciphers/utils';
+import type { NPub } from '@nostr/tools/nip19';
+
+// generate invite code
+// broadcast invite event to CCN { "invite": sha512Hash(inviteCode) }
+// invitee receives invite code
+// invitee generates nsec
+// invitee generates DM with their invite code to the CCN
+// upon receipt of message where sha512Hash(message) matches a valid invite code, the CCN (inviter only) will broadcast an accept invite event to all peers
+// CCN (inviter only) will DM CCN seed to invitee
+// CCN invalidates invite code
+
+@customElement('arx-invite-code-generator')
+export class InviteCodeGenerator extends LitElement {
+ static override styles = css`
+ :host {
+ display: block;
+ padding: 32px;
+ max-width: 600px;
+ margin: 0 auto;
+ }
+ `;
+
+ @state() inviteCode = '';
+ @state() ccnNpub?: NPub;
+ @state() ccnName = '';
+ @state() isGenerating = false;
+ @state() generatedInvite = '';
+
+ override async connectedCallback() {
+ super.connectedCallback();
+ const npub = getActiveCCNNpub();
+ if (!npub) throw new Error('No CCN selected');
+ const ccnName = await getCCNName(npub);
+ if (!ccnName) throw new Error('CCN name not found');
+ this.ccnNpub = npub;
+ this.ccnName = ccnName;
+ }
+
+ private async generateAndBroadcastInvite() {
+ if (this.inviteCode) return alert('Invite already generated');
+ if (this.isGenerating) return;
+ this.isGenerating = true;
+ try {
+ this.inviteCode = bytesToHex(randomBytes(32));
+ const event = new NDKEvent(ndk);
+ event.kind = inviteKind;
+ event.content = '';
+ event.tags = [['i', sha512Hash(this.inviteCode)]];
+ await event.sign();
+ await event.publish();
+ this.generatedInvite = generateInvite(this.ccnNpub!, this.inviteCode);
+ } catch (error) {
+ console.error('Failed to generate and broadcast invite:', error);
+ } finally {
+ this.isGenerating = false;
+ }
+ }
+
+ private async copyToClipboard() {
+ if (!this.generatedInvite) return;
+ await navigator.clipboard.writeText(this.generatedInvite);
+ }
+
+ override render() {
+ return html`
+
+ ${when(
+ this.inviteCode,
+ () => html`
+
+
+ `,
+ )}
+ `;
+ }
+}
diff --git a/src/components/Setup/CCNList.ts b/src/components/Setup/CCNList.ts
index de16349..b206447 100644
--- a/src/components/Setup/CCNList.ts
+++ b/src/components/Setup/CCNList.ts
@@ -36,7 +36,7 @@ export class CCNList extends LitElement {
We found one CCN in your relay, which likely means you've used Eve before or there was an update introducing new setup steps. You can use this CCN to proceed with the setup, or create a new one.
CCN Name: ${this.ccns[0].name}
this._handleCCNSelected(this.ccns[0].pubkey)}" label="Use This CCN">
- this._handleCCNSelected(undefined)}" label="Create New CCN">
+ this._handleCCNSelected(undefined)}" label="New CCN">
`;
return html`
@@ -49,7 +49,7 @@ export class CCNList extends LitElement {
.textMapper="${this._textMapper}"
@change="${(e: CustomEvent<{ value: string }>) => this._handleCCNSelected(e.detail.value)}"
>
- this._handleCCNSelected(undefined)}" label="Create New CCN">
+ this._handleCCNSelected(undefined)}" label="New CCN">
`;
}
diff --git a/src/components/Setup/SeedPhraseSpinner.ts b/src/components/Setup/SeedPhraseSpinner.ts
new file mode 100644
index 0000000..976b682
--- /dev/null
+++ b/src/components/Setup/SeedPhraseSpinner.ts
@@ -0,0 +1,160 @@
+import { ArxInputChangeEvent } from '@/components/General/Input';
+import * as nip06 from '@nostr/tools/nip06';
+import { wordlist } from '@scure/bip39/wordlists/english';
+import { LitElement, css, html } from 'lit';
+import { customElement, state } from 'lit/decorators.js';
+import { map } from 'lit/directives/map.js';
+import { when } from 'lit/directives/when.js';
+
+import '@components/General/Button';
+
+@customElement('arx-seed-phrase-spinner')
+export class SeedPhraseSpinner extends LitElement {
+ @state()
+ private words: (string | null)[] = [null, null, null, null, null, null, null, null, null, null, null, null];
+
+ @state()
+ private animating = false;
+
+ @state()
+ private tempWords: (string | null)[] = [null, null, null, null, null, null, null, null, null, null, null, null];
+
+ private getRandomWord() {
+ return wordlist[Math.floor(Math.random() * wordlist.length)];
+ }
+
+ static override styles = css`
+ .words-list {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 0.8rem;
+ width: 100%;
+ margin-bottom: 1rem;
+ }
+
+ .word {
+ width: 100%;
+ height: 3.5rem;
+ overflow: hidden;
+ position: relative;
+ border-radius: 0.5rem;
+ background: rgba(0, 0, 0, 0.05);
+ box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1);
+ }
+
+ .word::before, .word::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ height: 0.5rem;
+ z-index: 1;
+ pointer-events: none;
+ }
+
+ .word::before {
+ top: 0;
+ background: linear-gradient(to bottom, rgba(255, 255, 255, 0.7), transparent);
+ }
+
+ .word::after {
+ bottom: 0;
+ background: linear-gradient(to top, rgba(255, 255, 255, 0.7), transparent);
+ }
+
+ .word-inner {
+ display: flex;
+ flex-direction: column;
+ position: absolute;
+ width: 100%;
+ transition: transform 0.8s cubic-bezier(0.4, 0, 0.2, 1);
+ will-change: transform;
+ }
+
+ .word-item {
+ height: 3.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1.5rem;
+ font-weight: bold;
+ text-align: center;
+ padding: 0 0.25rem;
+ }
+
+ .controls {
+ display: flex;
+ justify-content: center;
+ margin-top: 1rem;
+ }
+
+ @keyframes spin {
+ 0% { transform: translateY(0); }
+ 100% { transform: translateY(-3.5rem); }
+ }
+
+ .spinning .word-inner {
+ animation: spin var(--animation-duration) var(--animation-curve) infinite;
+ }
+
+ .placeholder {
+ opacity: 0.5;
+ }
+ `;
+
+ override render() {
+ return html`
+
+ ${map(
+ this.tempWords,
+ (word) =>
+ html`
+
+
+ ${when(
+ this.animating,
+ () => html`
+
${word}
+
${word}
+ `,
+ () =>
+ html`
+
${word || 'tap generate'}
+ `,
+ )}
+
+
+ `,
+ )}
+
+
+ `;
+ }
+
+ private generateSeedPhrase() {
+ if (this.animating) return;
+
+ this.animating = true;
+
+ const intervalId = window.setInterval(() => {
+ this.tempWords = this.tempWords.map(() => this.getRandomWord());
+ }, 50);
+
+ setTimeout(() => {
+ const newWords = nip06.generateSeedWords().split(' ');
+ clearInterval(intervalId);
+ this.tempWords = newWords;
+ this.words = newWords;
+ this.animating = false;
+ this.dispatchEvent(new ArxInputChangeEvent(this.words.join(' ')));
+ }, 1500);
+ }
+}
diff --git a/src/ndk.ts b/src/ndk.ts
index ef844aa..698b177 100644
--- a/src/ndk.ts
+++ b/src/ndk.ts
@@ -1,4 +1,4 @@
-import NDK, { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
+import NDK, { type NDKKind, NDKPrivateKeySigner } from '@nostr-dev-kit/ndk';
import * as nip49 from '@nostr/tools/nip49';
export const ndk = new NDK({
@@ -37,3 +37,6 @@ export async function getUserProfile(npub: string | undefined = undefined) {
await user.fetchProfile();
return user.profile;
}
+
+export const inviteResponseKind = 9998 as NDKKind;
+export const inviteKind = 9999 as NDKKind;
diff --git a/src/routes/Home.ts b/src/routes/Home.ts
index 37b5ab4..922d30e 100644
--- a/src/routes/Home.ts
+++ b/src/routes/Home.ts
@@ -77,7 +77,6 @@ export class Home extends LitElement {
color: '#7B68EE',
icon: 'arx:settings',
},
- },
];
widgets = [
diff --git a/src/routes/Settings.ts b/src/routes/Settings.ts
index 56c01d8..6501c63 100644
--- a/src/routes/Settings.ts
+++ b/src/routes/Settings.ts
@@ -14,6 +14,7 @@ import '@components/General/Button';
import '@components/General/Card';
import '@components/General/Fieldset';
import '@components/General/Input';
+import '@components/InviteCodeGenerator';
import '@components/RelayLogs';
@customElement('arx-settings')
@@ -217,6 +218,11 @@ export class EveSettings extends LitElement {
+
+
+
+
+
diff --git a/src/routes/router.ts b/src/routes/router.ts
index 951866f..00649ff 100644
--- a/src/routes/router.ts
+++ b/src/routes/router.ts
@@ -159,14 +159,14 @@ export default class EveRouter extends LitElement {
::-webkit-scrollbar-thumb:hover {
background: var(--color-neutral);
}
-
+
.window {
overflow: auto;
grid-column: 2;
grid-row: 2;
position: relative;
}
-
+
.window::after {
content: '';
position: absolute;
@@ -179,7 +179,7 @@ export default class EveRouter extends LitElement {
background: linear-gradient(to right, transparent, rgba(0, 0, 0, 0.03));
transition: opacity 0.3s ease;
}
-
+
.window:hover::after {
opacity: 1;
}
@@ -235,7 +235,7 @@ export default class EveRouter extends LitElement {
transform: perspective(1200px) translateX(50vw);
filter: blur(50px);
}
-
+
.window-content.transitioning::after {
opacity: 1;
}
@@ -500,7 +500,7 @@ export default class EveRouter extends LitElement {
() => html``,
)}
- (array: T[]): T[] {
+ return [...new Set(array)];
+}
diff --git a/src/utils/dm.ts b/src/utils/dm.ts
new file mode 100644
index 0000000..db01e40
--- /dev/null
+++ b/src/utils/dm.ts
@@ -0,0 +1,65 @@
+import { inviteResponseKind, ndk } from '@/ndk';
+import { bytesToHex } from '@noble/hashes/utils';
+import { NDKEvent, NDKKind, NDKPrivateKeySigner, type NDKTag, NDKUser } from '@nostr-dev-kit/ndk';
+import type { NPub } from '@nostr/tools/nip19';
+import * as nostrTools from '@nostr/tools/pure';
+import { base64 } from '@scure/base';
+import { arrayToUnique } from './arrayToUnique';
+export async function generateDM(npub: NPub, content: string, additionalTags: NDKTag[] = []) {
+ if (!ndk.signer) {
+ throw new Error('NDK signer not initialized');
+ }
+
+ const recipient = new NDKUser({
+ npub,
+ });
+
+ const randomPrivateKey = nostrTools.generateSecretKey();
+ const randomSigner = new NDKPrivateKeySigner(randomPrivateKey);
+
+ const dmEvent = new NDKEvent(ndk);
+ dmEvent.kind = NDKKind.PrivateDirectMessage;
+ dmEvent.content = content;
+ dmEvent.tags.push(['p', recipient.pubkey], ...additionalTags);
+ await dmEvent.sign(ndk.signer);
+
+ const sealEvent = new NDKEvent(ndk);
+ sealEvent.kind = NDKKind.GiftWrapSeal;
+ sealEvent.content = await randomSigner.encrypt(recipient, JSON.stringify(dmEvent), 'nip44');
+ await sealEvent.sign(randomSigner);
+
+ const giftWrapEvent = new NDKEvent(ndk);
+ giftWrapEvent.kind = NDKKind.GiftWrap;
+ giftWrapEvent.tags.push(['p', recipient.pubkey], ...additionalTags);
+ giftWrapEvent.content = await randomSigner.encrypt(recipient, JSON.stringify(sealEvent), 'nip44');
+ await giftWrapEvent.sign(randomSigner);
+
+ return giftWrapEvent;
+}
+
+export async function decryptInviteResponseDM(event: NDKEvent): Promise<{
+ privateKey: string;
+ existingMembers: string[];
+ ccnName: string;
+}> {
+ if (event.kind !== NDKKind.GiftWrap) {
+ throw new Error('Event is not a gift wrap');
+ }
+
+ const seal = JSON.parse(await ndk.signer?.decrypt(event.author, event.content, 'nip44')!);
+ if (seal.kind !== NDKKind.GiftWrapSeal) {
+ throw new Error('Seal is not a gift wrap seal');
+ }
+
+ const inviteResponse = JSON.parse(await ndk.signer?.decrypt(event.author, seal.content, 'nip44')!);
+ if (inviteResponse.kind !== inviteResponseKind) {
+ throw new Error('Invite response is not of correct type');
+ }
+
+ const privateKey = base64.decode(inviteResponse.content);
+ return {
+ privateKey: bytesToHex(privateKey),
+ existingMembers: arrayToUnique(inviteResponse.tags.filter((tag) => tag[0] === 'p').map((tag) => tag[1])),
+ ccnName: inviteResponse.tags.find((tag) => tag[0] === 'name')?.[1],
+ };
+}
diff --git a/src/utils/getCCNList.ts b/src/utils/getCCNList.ts
new file mode 100644
index 0000000..a044b15
--- /dev/null
+++ b/src/utils/getCCNList.ts
@@ -0,0 +1,33 @@
+import { nip19 } from '@nostr/tools';
+import type { NPub } from '@nostr/tools/nip19';
+
+export function getCCNList(): Promise<{ name: string; pubkey: string }[]> {
+ return new Promise((resolve, reject) => {
+ const ws = new WebSocket('ws://localhost:6942');
+ ws.onopen = () => {
+ ws.send(JSON.stringify(['CCN', 'LIST']));
+ };
+ ws.onmessage = (event) => {
+ const data = JSON.parse(event.data);
+ if (data[0] !== 'OK' || data[1] !== 'CCN LIST' || data[2] !== true) return;
+ resolve(JSON.parse(data[3]));
+ };
+ ws.onerror = (event) => {
+ reject(event);
+ };
+ });
+}
+
+export function getActiveCCNNpub() {
+ const selectedCCN = localStorage.getItem('selectedCCN');
+ if (!selectedCCN) return null;
+ const { pubkey } = JSON.parse(selectedCCN);
+ return nip19.npubEncode(pubkey);
+}
+
+export async function getCCNName(npub: NPub) {
+ const ccnList = await getCCNList();
+ const npubToPubkey = nip19.decode<'npub'>(npub);
+ const ccn = ccnList.find((ccn) => ccn.pubkey === npubToPubkey.data);
+ return ccn?.name;
+}
diff --git a/src/utils/invite.ts b/src/utils/invite.ts
new file mode 100644
index 0000000..3ffeac6
--- /dev/null
+++ b/src/utils/invite.ts
@@ -0,0 +1,19 @@
+import { bytesToHex, hexToBytes } from '@noble/ciphers/utils';
+import { nip19 } from '@nostr/tools';
+import type { NPub } from '@nostr/tools/nip19';
+import { bech32m } from '@scure/base';
+
+export function generateInvite(ccn: NPub, invite: string) {
+ const npubHex = nip19.decode<'npub'>(ccn).data;
+ const combinedBytes = bech32m.toWords(new Uint8Array([...hexToBytes(npubHex), ...hexToBytes(invite)]));
+ return bech32m.encode('eveinvite', combinedBytes, false);
+}
+
+export function readInvite(invite: `${string}1${string}`) {
+ const decoded = bech32m.decode(invite, false);
+ if (decoded.prefix !== 'eveinvite') return false;
+ const hexBytes = bech32m.fromWords(decoded.words);
+ const npub = nip19.npubEncode(bytesToHex(hexBytes.slice(0, 32)));
+ const inviteCode = bytesToHex(hexBytes.slice(32));
+ return { npub, invite: inviteCode };
+}
diff --git a/src/utils/sha512.ts b/src/utils/sha512.ts
new file mode 100644
index 0000000..4d0a144
--- /dev/null
+++ b/src/utils/sha512.ts
@@ -0,0 +1,6 @@
+import { bytesToHex } from '@noble/ciphers/utils';
+import { sha512 } from '@noble/hashes/sha512';
+
+export function sha512Hash(input: string) {
+ return bytesToHex(sha512.create().update(input).digest());
+}
diff --git a/src/utils/unsafeRelaySet.ts b/src/utils/unsafeRelaySet.ts
new file mode 100644
index 0000000..c860721
--- /dev/null
+++ b/src/utils/unsafeRelaySet.ts
@@ -0,0 +1,25 @@
+import { ndk } from '@/ndk';
+import { NDKRelay, NDKRelaySet } from '@nostr-dev-kit/ndk';
+
+/**
+ * This is an unsafe relay set that is used to publish events publicly
+ * This should NEVER be used except for the initial setup
+ */
+export const unsafeRelaySet = new NDKRelaySet(
+ new Set([
+ new NDKRelay('wss://nos.lol/', undefined, ndk),
+ new NDKRelay('wss://nostr.einundzwanzig.space/', undefined, ndk),
+ new NDKRelay('wss://nostr.massmux.com/', undefined, ndk),
+ new NDKRelay('wss://nostr.mom/', undefined, ndk),
+ new NDKRelay('wss://purplerelay.com/', undefined, ndk),
+ new NDKRelay('wss://relay.damus.io/', undefined, ndk),
+ new NDKRelay('wss://relay.goodmorningbitcoin.com/', undefined, ndk),
+ new NDKRelay('wss://relay.lexingtonbitcoin.org/', undefined, ndk),
+ new NDKRelay('wss://relay.nostr.band/', undefined, ndk),
+ new NDKRelay('wss://relay.primal.net/', undefined, ndk),
+ new NDKRelay('wss://relay.snort.social/', undefined, ndk),
+ new NDKRelay('wss://strfry.iris.to/', undefined, ndk),
+ new NDKRelay('wss://cache2.primal.net/v1', undefined, ndk),
+ ]),
+ ndk,
+);