Merge 3c7795e121a0d305040401d5b4e6fdd7231648b5 into fca33640692a0c1cae431585782aa799042751a7

This commit is contained in:
Sasha 2025-11-10 12:58:07 +00:00 committed by GitHub
commit eac385859f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 7470 additions and 0 deletions

35
examples/flush-notes/.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@ -0,0 +1,16 @@
## Waku dependencies
- @waku/interfaces
- @waku/message-encryption
- @waku/sdk
- @waku/utils
## Description
Exchange encrypted or plain notes by link.
This example shows how symmetric encryption can be used to encrypt only part of Waku message.
## How to run
```bash
npm start
# or
yarn start
```

View File

@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "export",
async rewrites() {
return [
{
source: "/view/:path*",
destination: "/view",
},
];
},
};
module.exports = nextConfig;

6902
examples/flush-notes/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
{
"name": "flush-notes",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build",
"start": "next dev",
"lint": "next lint"
},
"dependencies": {
"@waku/interfaces": "0.0.21-7eb3375.0",
"@waku/message-encryption": "0.0.24-7eb3375.0",
"@waku/sdk": "0.0.22-7eb3375.0",
"@waku/utils": "0.0.14-7eb3375.0",
"next": "14.0.2",
"react": "^18",
"react-dom": "^18",
"react-markdown": "^9.0.1"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "14.0.2",
"postcss": "^8",
"typescript": "^5"
}
}

View File

@ -0,0 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,39 @@
* {
box-sizing: border-box;
}
.loading-block {
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.note-info {
font-size: 0.8rem;
text-align: right;
}
.create-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.to-encrypt {
font-size: 1rem;
padding: 5px;
}
.save-note {
width: 100px;
height: 50px;
margin-bottom: 10px;
}
.note-value {
width: 100%;
padding: 10px;
min-height: 500px;
}

View File

@ -0,0 +1,23 @@
"use client";
import React from "react";
import { Inter } from "next/font/google";
import { WakuProvider } from "@/components/WakuProvider";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<title>Share notes</title>
<body className={inter.className}>
<WakuProvider>{children}</WakuProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,57 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { notes } from "@/services/notes";
export default function Create() {
const router = useRouter();
const { note, onNoteChange } = useEditNote();
const onSave = async () => {
if (!note) {
return;
}
try {
const { id, key } = await notes.createNote(note);
const passwordParam = `?key=${key}`;
router.push(`/view/${id}${passwordParam}`);
} catch (error) {
console.error("Failed to create a note:", error);
}
};
return (
<div>
<p className="note-info">
Your record will be stored for couple of days. Markdown is supported.
</p>
<div className="create-header">
<div></div>
<button onClick={onSave} className="save-note">
Save note
</button>
</div>
<textarea
className="note-value"
value={note}
onChange={onNoteChange}
></textarea>
</div>
);
}
const useEditNote = () => {
const [state, setState] = React.useState<string>("");
const onNoteChange = (event: React.FormEvent<HTMLTextAreaElement>) => {
setState(event?.currentTarget?.value);
};
return {
note: state,
onNoteChange,
};
};

View File

@ -0,0 +1,31 @@
"use client";
import React from "react";
import Markdown from "react-markdown";
import { useRouter } from "next/navigation";
import { useNoteURL } from "@/hooks/useNoteURL";
import { notes } from "@/services/notes";
import { Loading } from "@/components/Loading";
const View = () => {
const router = useRouter();
const { id, key } = useNoteURL();
const [note, setNote] = React.useState<string>("");
React.useEffect(() => {
if (!id) {
router.replace("/404");
return;
}
notes.readNote(id, key).then((note) => setNote(note || ""));
}, [id, key, setNote]);
if (!note) {
return <Loading />;
}
return <Markdown>{note}</Markdown>;
};
export default View;

View File

@ -0,0 +1,7 @@
export const Loading = () => {
return (
<div className="loading-block">
<p>Loading...</p>
</div>
);
};

View File

@ -0,0 +1,65 @@
"use client";
import React from "react";
import { waku, Waku, WakuEvents } from "@/services/waku";
import { WakuStatus } from "@/const";
import { Loading } from "@/components/Loading";
type WakuContextProps = {
status: WakuStatus;
waku?: Waku;
};
const WakuContext = React.createContext<WakuContextProps>({
status: WakuStatus.Initializing,
waku: undefined,
});
type WakuProviderProps = {
children: React.ReactNode;
};
export const useWaku = () => {
const { status, waku } = React.useContext(WakuContext);
return { status, waku };
};
export const WakuProvider = (props: WakuProviderProps) => {
const wakuRef = React.useRef<Waku>();
const [status, setStatus] = React.useState<WakuStatus>(
WakuStatus.Initializing
);
React.useEffect(() => {
if (wakuRef.current) {
return;
}
const statusListener = (event: CustomEvent) => {
setStatus(event.detail);
};
waku.addEventListener(WakuEvents.Status, statusListener);
waku.init().then(() => {
wakuRef.current = waku;
});
return () => {
waku.removeEventListener(WakuEvents.Status, statusListener);
};
}, [wakuRef, setStatus]);
if (status === WakuStatus.Failed) {
return <>{status}</>;
}
if (status !== WakuStatus.Connected) {
return <Loading />;
}
return (
<WakuContext.Provider value={{ status, waku: wakuRef.current }}>
{props.children}
</WakuContext.Provider>
);
};

View File

@ -0,0 +1,8 @@
export const CONTENT_TOPIC = "/flush-notes/1/note/proto";
export enum WakuStatus {
Initializing = "Initializing...",
WaitingForPeers = "Waiting for peers...",
Connected = "Connected",
Failed = "Failed to initialize(see logs)",
}

View File

@ -0,0 +1,15 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
export const useNoteURL = (): { id: string; key: string } => {
const pathname = usePathname();
const params = useSearchParams();
const segments = pathname.split("/");
const viewIndex = segments.indexOf("view");
const key = params.get("key");
const id = segments[viewIndex + 1];
return { key, id };
};

View File

@ -0,0 +1,91 @@
"use client";
import { waku } from "@/services/waku";
import { CONTENT_TOPIC } from "@/const";
import {
createEncoder,
createDecoder,
DecodedMessage,
generateSymmetricKey,
} from "@waku/message-encryption/symmetric";
import { Unsubscribe, utf8ToBytes, bytesToUtf8 } from "@waku/sdk";
import { bytesToHex, hexToBytes } from "@waku/utils/bytes";
const UUID_V4_STR_LEN = 8 + 1 + 4 + 1 + 4 + 1 + 4 + 1 + 12; // 8-4-4-4-12 format
type Note = {
id: string;
content: string;
};
type NoteResult = {
id: string;
key: string;
};
export class Notes {
private messages: DecodedMessage[] = [];
private subscription: undefined | Unsubscribe;
constructor() {}
public async createNote(content: string): Promise<NoteResult> {
const symKey = generateSymmetricKey();
const encoder = createEncoder({ contentTopic: CONTENT_TOPIC, symKey });
const id = self.crypto.randomUUID();
if (id.length !== UUID_V4_STR_LEN) {
throw "Unexpected uuid length";
}
await waku.send(encoder, {
payload: utf8ToBytes(id + content),
});
return {
id,
key: bytesToHex(symKey),
};
}
public async readNote(id: string, key: string): Promise<string | undefined> {
await this.initMessages(hexToBytes(key));
const message = this.messages
.map((m) => {
try {
const str = bytesToUtf8(m.payload);
const id = str.substring(0, UUID_V4_STR_LEN);
const content = str.substring(UUID_V4_STR_LEN);
return { id, content } as Note;
} catch (error) {
console.log("Failed to read message:", error);
}
})
.find((v) => {
if (v?.id === id) {
return true;
}
});
return message?.content;
}
private async initMessages(key: Uint8Array) {
if (this.subscription) {
return;
}
const decoder = createDecoder(CONTENT_TOPIC, key);
this.messages = await waku.getHistory(decoder);
this.subscription = await waku.subscribe(decoder, (message) => {
this.messages.push(message);
});
}
}
export const notes = new Notes();

View File

@ -0,0 +1,105 @@
"use client";
import { WakuStatus } from "@/const";
import {
IDecoder,
IEncoder,
IMessage,
LightNode,
createLightNode,
waitForRemotePeer,
IDecodedMessage,
} from "@waku/sdk";
type EventListener = (event: CustomEvent) => void;
export enum WakuEvents {
Status = "status",
}
export class Waku {
private node: undefined | LightNode;
private emitter = new EventTarget();
private initialized: boolean = false;
private initializing: boolean = false;
constructor() {}
public async init(): Promise<void> {
if (this.initialized || this.initializing) {
return;
}
this.initializing = true;
try {
this.emitStatusEvent(WakuStatus.Initializing);
const node = await createLightNode({ defaultBootstrap: true });
await node.start();
this.emitStatusEvent(WakuStatus.WaitingForPeers);
await waitForRemotePeer(node);
this.node = node;
this.initialized = true;
this.emitStatusEvent(WakuStatus.Connected);
} catch (error) {
console.error("Failed to initialize Waku node:", error);
this.emitStatusEvent(WakuStatus.Failed);
}
this.initializing = false;
}
public addEventListener(event: WakuEvents, fn: EventListener) {
return this.emitter.addEventListener(event, fn as any);
}
public removeEventListener(event: WakuEvents, fn: EventListener) {
return this.emitter.removeEventListener(event, fn as any);
}
public send(encoder: IEncoder, message: IMessage) {
this.ensureWakuInitialized();
return this.node?.lightPush.send(encoder, message);
}
public async getHistory(
decoder: IDecoder<IDecodedMessage>
): Promise<IDecodedMessage[]> {
this.ensureWakuInitialized();
let messages: IDecodedMessage[] = [];
for await (const promises of this.node!.store.queryGenerator([decoder])) {
const messagesRaw = await Promise.all(promises);
const filteredMessages = messagesRaw.filter(
(v): v is IDecodedMessage => !!v
);
messages = [...messages, ...filteredMessages];
}
return messages;
}
public async subscribe(
decoder: IDecoder<IDecodedMessage>,
fn: (m: IDecodedMessage) => void
) {
this.ensureWakuInitialized();
return this.node!.filter.subscribe(decoder, fn);
}
private emitStatusEvent(payload: string) {
this.emitter.dispatchEvent(
new CustomEvent(WakuEvents.Status, { detail: payload })
);
}
private ensureWakuInitialized() {
if (!waku.initialized) {
const message = "Waku is not initialized.";
console.log(message);
throw Error(message);
}
}
}
export const waku = new Waku();

View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}