2023-06-30 14:05:57 +00:00
|
|
|
/*
|
|
|
|
Extension to common Nostr types
|
|
|
|
*/
|
2023-07-12 07:14:45 +00:00
|
|
|
import { PrivateKey, PublicKey } from "https://raw.githubusercontent.com/BlowaterNostr/nostr.ts/main/key.ts";
|
2023-06-30 14:05:57 +00:00
|
|
|
import * as nostr from "https://raw.githubusercontent.com/BlowaterNostr/nostr.ts/main/nostr.ts";
|
|
|
|
import {
|
|
|
|
groupBy,
|
|
|
|
NostrKind,
|
|
|
|
prepareEncryptedNostrEvent,
|
|
|
|
prepareNormalNostrEvent,
|
|
|
|
TagPubKey,
|
|
|
|
} from "https://raw.githubusercontent.com/BlowaterNostr/nostr.ts/main/nostr.ts";
|
2023-07-14 10:59:25 +00:00
|
|
|
import { ProfileData } from "./features/profile.ts";
|
2023-06-30 14:05:57 +00:00
|
|
|
|
|
|
|
type TotolChunks = string;
|
|
|
|
type ChunkIndex = string; // 0-indexed
|
|
|
|
type GroupLeadEventID = string;
|
|
|
|
export type TagImage = ["image", GroupLeadEventID, TotolChunks, ChunkIndex];
|
|
|
|
export type TagClient = ["client", "blowater"];
|
|
|
|
export type TagLamportTimestamp = ["lamport", string];
|
|
|
|
export type TagReply = ["e", nostr.EventID, RelayURL, Marker];
|
|
|
|
type Marker = "reply" | "root" | "mention";
|
|
|
|
type RelayURL = string;
|
|
|
|
|
|
|
|
export type Tag = nostr.Tag | TagImage | TagClient | TagLamportTimestamp | TagReply;
|
|
|
|
|
|
|
|
type Tags = {
|
|
|
|
image?: [GroupLeadEventID, TotolChunks, ChunkIndex];
|
|
|
|
lamport_timestamp?: number;
|
|
|
|
reply?: [nostr.EventID, RelayURL, "reply"];
|
|
|
|
root?: [nostr.EventID, RelayURL, "root"];
|
|
|
|
} & nostr.Tags;
|
|
|
|
|
2023-07-14 10:38:45 +00:00
|
|
|
type Event = nostr.NostrEvent<NostrKind, Tag>;
|
|
|
|
type UnsignedEvent = nostr.UnsignedNostrEvent<NostrKind, Tag>;
|
2023-06-30 14:05:57 +00:00
|
|
|
|
|
|
|
export function getTags(event: Event): Tags {
|
|
|
|
const tags: Tags = {
|
|
|
|
p: [],
|
|
|
|
e: [],
|
|
|
|
};
|
|
|
|
for (const tag of event.tags) {
|
|
|
|
switch (tag[0]) {
|
|
|
|
case "p":
|
|
|
|
tags.p.push(tag[1]);
|
|
|
|
break;
|
|
|
|
case "e":
|
|
|
|
if (tag[3] == "reply") {
|
|
|
|
const [_1, EventID, RelayURL, _2] = tag;
|
|
|
|
tags.reply = [EventID, RelayURL as string, "reply"];
|
|
|
|
} else if (tag[3] == "root") {
|
|
|
|
const [_1, EventID, RelayURL, _2] = tag;
|
|
|
|
tags.root = [EventID, RelayURL as string, "root"];
|
2023-07-07 09:28:46 +00:00
|
|
|
} else if (tag[1] != "") {
|
2023-06-30 14:05:57 +00:00
|
|
|
tags.e.push(tag[1]);
|
|
|
|
}
|
|
|
|
break;
|
|
|
|
case "image":
|
|
|
|
const [_, GroupLeadEventID, TotolChunks, ChunkIndex] = tag;
|
|
|
|
tags.image = [GroupLeadEventID, TotolChunks, ChunkIndex];
|
|
|
|
break;
|
|
|
|
case "client":
|
|
|
|
tags.client = tag[1];
|
|
|
|
break;
|
|
|
|
case "lamport":
|
|
|
|
tags.lamport_timestamp = Number(tag[1]);
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return tags;
|
|
|
|
}
|
|
|
|
|
|
|
|
export async function prepareNostrImageEvents(
|
|
|
|
sender: nostr.NostrAccountContext,
|
2023-07-11 09:19:57 +00:00
|
|
|
receiverPublicKey: PublicKey,
|
2023-06-30 14:05:57 +00:00
|
|
|
blob: Blob,
|
|
|
|
kind: nostr.NostrKind,
|
|
|
|
tags?: Tag[],
|
|
|
|
): Promise<[nostr.NostrEvent[], string] | Error> {
|
|
|
|
// prepare nostr event
|
|
|
|
// read the blob
|
|
|
|
const binaryContent = await nostr.blobToBase64(blob);
|
|
|
|
|
|
|
|
const chunkSize = 32 * 1024;
|
|
|
|
const chunkCount = Math.ceil(binaryContent.length / chunkSize);
|
|
|
|
const events: nostr.NostrEvent[] = [];
|
2023-07-01 14:51:28 +00:00
|
|
|
let groupLeadEventID = PrivateKey.Generate().hex;
|
2023-06-30 14:05:57 +00:00
|
|
|
for (let i = 0; i < chunkCount; i++) {
|
|
|
|
const chunk = binaryContent.slice(i * chunkSize, (i + 1) * chunkSize);
|
|
|
|
// encryption
|
2023-07-11 09:19:57 +00:00
|
|
|
const encrypted = await sender.encrypt(receiverPublicKey.hex, chunk);
|
2023-06-30 14:05:57 +00:00
|
|
|
if (encrypted instanceof Error) {
|
|
|
|
return encrypted;
|
|
|
|
}
|
|
|
|
|
|
|
|
const event: UnsignedEvent = {
|
|
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
|
|
kind: kind,
|
|
|
|
pubkey: sender.publicKey.hex,
|
|
|
|
tags: [
|
2023-07-11 09:19:57 +00:00
|
|
|
["p", receiverPublicKey.hex],
|
2023-06-30 14:05:57 +00:00
|
|
|
["image", groupLeadEventID, String(chunkCount), String(i)],
|
|
|
|
...(tags || []),
|
|
|
|
],
|
|
|
|
content: encrypted,
|
|
|
|
};
|
|
|
|
events.push(await sender.signEvent(event));
|
|
|
|
}
|
|
|
|
return [events, groupLeadEventID];
|
|
|
|
}
|
|
|
|
|
|
|
|
export function reassembleBase64ImageFromEvents(
|
|
|
|
events: nostr.NostrEvent[],
|
|
|
|
) {
|
|
|
|
if (events.length === 0) {
|
|
|
|
return "";
|
|
|
|
}
|
|
|
|
|
|
|
|
const firstEvent = events[0];
|
|
|
|
const imageTag = getTags(firstEvent).image;
|
|
|
|
if (imageTag == undefined) {
|
|
|
|
return new Error(`${firstEvent.id} is not an image event`);
|
|
|
|
}
|
|
|
|
|
|
|
|
const [_2, chunkCount, _4] = imageTag;
|
|
|
|
const chunks = new Array<string | null>(Number(chunkCount));
|
|
|
|
chunks.fill(null);
|
|
|
|
|
|
|
|
for (const event of events) {
|
|
|
|
const imageTag = getTags(event).image;
|
|
|
|
if (imageTag == undefined) {
|
|
|
|
return new Error(`${event.id} is not an image event`);
|
|
|
|
}
|
|
|
|
const [_2, _3, chunkIndex] = imageTag;
|
|
|
|
const cIndex = Number(chunkIndex);
|
|
|
|
chunks[cIndex] = event.content;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (chunks.includes(null)) {
|
|
|
|
const miss = chunks.filter((c) => c === null).length;
|
|
|
|
return new Error(
|
|
|
|
`not enough chunks for image event ${firstEvent.id}, need ${Number(chunkCount)}, miss ${miss}`,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
return chunks.join("");
|
|
|
|
}
|
|
|
|
|
|
|
|
export function groupImageEvents(events: Iterable<nostr.NostrEvent>) {
|
|
|
|
return groupBy(events, (event) => {
|
|
|
|
const tags = getTags(event);
|
|
|
|
const imageTag = tags.image;
|
|
|
|
if (imageTag == undefined) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
const groupID = imageTag[0];
|
|
|
|
return groupID;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
export function prepareReplyEvent(
|
|
|
|
sender: nostr.NostrAccountContext,
|
|
|
|
targetEvent: nostr.NostrEvent,
|
|
|
|
tags: Tag[],
|
|
|
|
content: string,
|
|
|
|
): Promise<nostr.NostrEvent | Error> {
|
|
|
|
const ps = getTags(targetEvent).p;
|
|
|
|
if (targetEvent.kind == NostrKind.DIRECT_MESSAGE) {
|
|
|
|
return prepareEncryptedNostrEvent(
|
|
|
|
sender,
|
|
|
|
targetEvent.pubkey,
|
|
|
|
targetEvent.kind,
|
|
|
|
[
|
|
|
|
[
|
|
|
|
"e",
|
|
|
|
targetEvent.id,
|
|
|
|
"",
|
|
|
|
"reply",
|
|
|
|
],
|
|
|
|
...ps.map((p) =>
|
|
|
|
[
|
|
|
|
"p",
|
|
|
|
p,
|
|
|
|
] as TagPubKey
|
|
|
|
),
|
|
|
|
...tags,
|
|
|
|
],
|
|
|
|
content,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return prepareNormalNostrEvent(
|
|
|
|
sender,
|
|
|
|
targetEvent.kind,
|
|
|
|
[
|
|
|
|
[
|
|
|
|
"e",
|
|
|
|
targetEvent.id,
|
|
|
|
"",
|
|
|
|
"reply",
|
|
|
|
],
|
|
|
|
...ps.map((p) =>
|
|
|
|
[
|
|
|
|
"p",
|
|
|
|
p,
|
|
|
|
] as TagPubKey
|
|
|
|
),
|
|
|
|
...tags,
|
|
|
|
],
|
|
|
|
content,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-07-13 09:06:35 +00:00
|
|
|
export function compare(a: ParsedTag_Nostr_Event, b: ParsedTag_Nostr_Event) {
|
|
|
|
if (a.parsedTags.lamport_timestamp && b.parsedTags.lamport_timestamp) {
|
|
|
|
return a.parsedTags.lamport_timestamp - b.parsedTags.lamport_timestamp;
|
2023-06-30 14:05:57 +00:00
|
|
|
}
|
|
|
|
return a.created_at - b.created_at;
|
|
|
|
}
|
|
|
|
|
2023-07-14 10:38:45 +00:00
|
|
|
export type ParsedTag_Nostr_Event<Kind extends NostrKind = NostrKind> = nostr.NostrEvent<Kind> & {
|
2023-07-13 09:06:35 +00:00
|
|
|
readonly parsedTags: Tags;
|
|
|
|
};
|
|
|
|
export function computeThreads(events: ParsedTag_Nostr_Event[]) {
|
2023-06-30 14:05:57 +00:00
|
|
|
events.sort(compare);
|
2023-07-13 09:06:35 +00:00
|
|
|
const idsMap = new Map<string, ParsedTag_Nostr_Event>();
|
2023-06-30 14:05:57 +00:00
|
|
|
for (const event of events) {
|
|
|
|
if (!idsMap.has(event.id)) {
|
|
|
|
idsMap.set(event.id, event);
|
|
|
|
}
|
2023-07-13 09:06:35 +00:00
|
|
|
const tags = event.parsedTags;
|
2023-06-30 14:05:57 +00:00
|
|
|
if (tags.image && tags.image[2] == "0" && !idsMap.has(tags.image[0])) {
|
|
|
|
idsMap.set(tags.image[0], event);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-13 09:06:35 +00:00
|
|
|
const relationsMap = new Map<ParsedTag_Nostr_Event, ParsedTag_Nostr_Event | string>();
|
2023-06-30 14:05:57 +00:00
|
|
|
for (const event of events) {
|
|
|
|
let id = event.id;
|
2023-07-13 09:06:35 +00:00
|
|
|
const replyTags = event.parsedTags.root || event.parsedTags.reply || event.parsedTags.e;
|
|
|
|
const imageTags = event.parsedTags.image;
|
2023-06-30 14:05:57 +00:00
|
|
|
if (replyTags && replyTags.length > 0) {
|
|
|
|
id = replyTags[0];
|
|
|
|
} else if (imageTags && imageTags.length > 0) {
|
|
|
|
id = imageTags[0];
|
|
|
|
}
|
|
|
|
|
|
|
|
const idsEvent = idsMap.get(id);
|
|
|
|
if (idsEvent) {
|
|
|
|
const relationEvent = relationsMap.get(idsEvent);
|
|
|
|
if (!relationEvent) {
|
|
|
|
relationsMap.set(event, idsEvent);
|
|
|
|
} else {
|
|
|
|
relationsMap.set(event, relationEvent);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
relationsMap.set(event, id);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-07-13 09:06:35 +00:00
|
|
|
const resMap = new Map<string, ParsedTag_Nostr_Event[]>();
|
2023-06-30 14:05:57 +00:00
|
|
|
for (const event of events) {
|
|
|
|
const relationEvent = relationsMap.get(event);
|
|
|
|
if (!relationEvent) {
|
|
|
|
throw Error("Impossible");
|
|
|
|
}
|
|
|
|
|
|
|
|
const id = typeof relationEvent == "string" ? relationEvent : relationEvent.id;
|
|
|
|
const res = resMap.get(id);
|
|
|
|
|
|
|
|
if (res) {
|
|
|
|
res.push(event);
|
|
|
|
} else {
|
|
|
|
resMap.set(id, [event]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return Array.from(resMap.values());
|
|
|
|
}
|
|
|
|
|
2023-07-14 10:38:45 +00:00
|
|
|
export type Decrypted_Nostr_Event = ParsedTag_Nostr_Event<NostrKind.CustomAppData> & {
|
2023-07-12 07:14:45 +00:00
|
|
|
readonly decryptedContent: string;
|
|
|
|
};
|
|
|
|
|
2023-07-14 10:38:45 +00:00
|
|
|
export type Decryptable_Nostr_Event = nostr.NostrEvent<NostrKind.CustomAppData>;
|
2023-07-12 08:45:55 +00:00
|
|
|
|
2023-07-14 10:38:45 +00:00
|
|
|
export type PlainText_Nostr_Event = ParsedTag_Nostr_Event<
|
2023-07-14 10:59:25 +00:00
|
|
|
Exclude<NostrKind, NostrKind.CustomAppData> // todo: exclude DM as well
|
2023-07-14 10:38:45 +00:00
|
|
|
>;
|
2023-07-14 10:59:25 +00:00
|
|
|
export type Profile_Nostr_Event = ParsedTag_Nostr_Event<NostrKind.META_DATA> & {
|
|
|
|
profile: ProfileData;
|
|
|
|
};
|
2023-06-30 14:05:57 +00:00
|
|
|
export type CustomAppData = PinContact | UnpinContact | UserLogin;
|
|
|
|
|
|
|
|
export type PinContact = {
|
|
|
|
type: "PinContact";
|
|
|
|
pubkey: string;
|
|
|
|
};
|
|
|
|
|
|
|
|
export type UnpinContact = {
|
|
|
|
type: "UnpinContact";
|
|
|
|
pubkey: string;
|
|
|
|
};
|
|
|
|
|
|
|
|
export type UserLogin = {
|
|
|
|
type: "UserLogin";
|
|
|
|
};
|