add invitation event to DirectedMessageController (#231)

This commit is contained in:
BlowaterNostr 2023-10-09 21:02:13 +00:00 committed by GitHub
parent 9b8d9aa47d
commit eab9d7fed6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 223 additions and 174 deletions

View File

@ -139,7 +139,7 @@ export class App {
const conversationLists = new ConversationLists(args.ctx, profileSyncer);
conversationLists.addEvents(args.database.events);
const dmControl = new DirectedMessageController(args.ctx);
const dmController = new DirectedMessageController(args.ctx);
const groupSyncer = new GroupChatSyncer(args.database, args.pool);
const groupChatController = new GroupMessageController(
@ -152,15 +152,22 @@ export class App {
(async () => {
for (const e of args.database.events) {
if (e.kind == NostrKind.Group_Message) {
const err = await groupChatController.addEvent({
let err = await groupChatController.addEvent({
...e,
kind: e.kind,
});
if (err instanceof Error) {
console.error(err.message);
}
err = await dmController.addEvent({
...e,
kind: e.kind,
});
if (err instanceof Error) {
console.error(err);
}
} else if (e.kind == NostrKind.DIRECT_MESSAGE) {
const error = await dmControl.addEvent({
const error = await dmController.addEvent({
...e,
kind: e.kind,
});
@ -190,7 +197,7 @@ export class App {
relayConfig,
groupChatController,
lamport,
dmControl,
dmController,
);
}

View File

@ -8,10 +8,10 @@ import { ConversationLists } from "./conversation-list.ts";
import * as csp from "https://raw.githubusercontent.com/BlowaterNostr/csp/master/csp.ts";
import { Database_Contextual_View } from "../database.ts";
import { convertEventsToChatMessages, DirectedMessageController, sendDMandImages } from "../features/dm.ts";
import { DirectedMessageController, sendDMandImages } from "../features/dm.ts";
import { notify } from "./notification.ts";
import { EventBus } from "../event-bus.ts";
import { ContactUpdate } from "./conversation-list.tsx";
import { ContactUpdate, IsGruopChatSupported } from "./conversation-list.tsx";
import { MyProfileUpdate } from "./edit-profile.tsx";
import { EditorEvent, EditorModel, new_DM_EditorModel, SendMessage } from "./editor.tsx";
import { DirectMessagePanelUpdate } from "./message-panel.tsx";
@ -569,7 +569,14 @@ export async function* Database_Update(
}
}
} else if (e.kind == NostrKind.Group_Message) {
const err = await groupController.addEvent({
let err = await groupController.addEvent({
...e,
kind: e.kind,
});
if (err instanceof Error) {
console.error(err);
}
err = await dmController.addEvent({
...e,
kind: e.kind,
});
@ -663,19 +670,19 @@ export async function handle_SendMessage(
}
} else {
// todo: hack, change later
// const invitation = isInvitation(event.text);
// if (invitation) {
// const invitationEvent = await groupControl.createInvitation(invitation, event.pubkey);
// if (invitationEvent instanceof Error) {
// return invitationEvent;
// }
// console.log(invitationEvent);
// const err = await pool.sendEvent(invitationEvent);
// if (err instanceof Error) {
// return err;
// }
// return;
// }
const invitation = isInvitation(event.text);
if (invitation && IsGruopChatSupported) {
const invitationEvent = await groupControl.createInvitation(invitation, event.pubkey);
if (invitationEvent instanceof Error) {
return invitationEvent;
}
console.log(invitationEvent);
const err = await pool.sendEvent(invitationEvent);
if (err instanceof Error) {
return err;
}
return;
}
const events = await sendDMandImages({
sender: ctx,
receiverPublicKey: event.pubkey,

View File

@ -3,7 +3,7 @@ import { PublicKey } from "../lib/nostr-ts/key.ts";
import { NostrAccountContext, NostrEvent, NostrKind } from "../lib/nostr-ts/nostr.ts";
import { getTags, Parsed_Event } from "../nostr.ts";
import { ProfileSyncer } from "../features/profile.ts";
import { GroupChatCreation } from "../features/gm.ts";
import { gm_Creation } from "../features/gm.ts";
export interface ConversationSummary {
pubkey: PublicKey;
@ -77,7 +77,7 @@ export class ConversationLists implements ConversationListRetriever, NewMessageC
}
}
addGroupCreation(groupChatCreation: GroupChatCreation) {
addGroupCreation(groupChatCreation: gm_Creation) {
const publicKey = groupChatCreation.groupKey.publicKey;
this.groupChatSummaries.set(publicKey.hex, {
pubkey: publicKey,

View File

@ -19,7 +19,7 @@ import { UI_Interaction_Event } from "./app_update.tsx";
import { ProfileData } from "../features/profile.ts";
import { ProfileGetter } from "./search.tsx";
const IsGruopChatSupported = false;
export const IsGruopChatSupported = false;
export interface ConversationListRetriever {
getContacts: () => Iterable<ConversationSummary>;

View File

@ -19,7 +19,6 @@ import { GroupMessageController } from "../features/gm.ts";
import { ProfileGetter } from "./search.tsx";
import { InviteIcon } from "./icons2/invite-icon.tsx";
import { PublicKey } from "../lib/nostr-ts/key.ts";
import { convertEventsToChatMessages } from "../features/dm.ts";
import { ChatMessage } from "./message.ts";
import { EditorModel } from "./editor.tsx";

View File

@ -9,7 +9,7 @@ import { IconButtonClass } from "./components/tw.ts";
import { sleep } from "https://raw.githubusercontent.com/BlowaterNostr/csp/master/csp.ts";
import { emitFunc } from "../event-bus.ts";
import { ChatMessage, groupContinuousMessages, sortMessage, urlIsImage } from "./message.ts";
import { ChatMessage, groupContinuousMessages, parseContent, sortMessage, urlIsImage } from "./message.ts";
import { PublicKey } from "../lib/nostr-ts/key.ts";
import { NostrEvent, NostrKind } from "../lib/nostr-ts/nostr.ts";
import { Parsed_Event, PinConversation, Profile_Nostr_Event, UnpinConversation } from "../nostr.ts";
@ -119,7 +119,7 @@ export class MessagePanel extends Component<DirectMessagePanelProps> {
<MessageList
myPublicKey={props.myPublicKey}
threads={props.messages}
messages={props.messages}
emit={props.emit}
profilesSyncer={props.profilesSyncer}
eventSyncer={props.eventSyncer}
@ -166,7 +166,7 @@ export class MessagePanel extends Component<DirectMessagePanelProps> {
}
interface MessageListProps {
myPublicKey: PublicKey;
threads: ChatMessage[];
messages: ChatMessage[];
emit: emitFunc<DirectMessagePanelUpdate>;
profilesSyncer: ProfileSyncer;
eventSyncer: EventSyncer;
@ -200,20 +200,20 @@ export class MessageList extends Component<MessageListProps, MessageListState> {
e.currentTarget.scrollTop < 1000
) {
const ok = await this.jitter.shouldExecute();
if (!ok || this.state.currentRenderCount >= this.props.threads.length) {
if (!ok || this.state.currentRenderCount >= this.props.messages.length) {
return;
}
this.setState({
currentRenderCount: Math.min(
this.state.currentRenderCount + ItemsOfPerPage,
this.props.threads.length,
this.props.messages.length,
),
});
}
};
sortAndSliceMessage = () => {
return sortMessage(this.props.threads)
return sortMessage(this.props.messages)
.slice(
0,
this.state.currentRenderCount,
@ -239,6 +239,7 @@ export class MessageList extends Component<MessageListProps, MessageListState> {
profilesSyncer: this.props.profilesSyncer,
eventSyncer: this.props.eventSyncer,
authorProfile: profileEvent ? profileEvent.profile : undefined,
profileGetter: this.props.profileGetter,
}),
);
}
@ -291,6 +292,7 @@ function MessageBoxGroup(props: {
emit: emitFunc<DirectMessagePanelUpdate | ViewUserDetail>;
profilesSyncer: ProfileSyncer;
eventSyncer: EventSyncer;
profileGetter: ProfileGetter;
}) {
const messageGroups = props.messages.reverse();
if (messageGroups.length == 0) {
@ -335,6 +337,7 @@ function MessageBoxGroup(props: {
props.profilesSyncer,
props.eventSyncer,
props.emit,
props.profileGetter,
)}
</pre>
</div>
@ -364,6 +367,7 @@ function MessageBoxGroup(props: {
props.profilesSyncer,
props.eventSyncer,
props.emit,
props.profileGetter
)}
</pre>
</div>
@ -455,18 +459,22 @@ export function ParseMessageContent(
profilesSyncer: ProfileSyncer,
eventSyncer: EventSyncer,
emit: emitFunc<ViewUserDetail | ViewThread | ViewNoteThread>,
profileGetter: ProfileGetter,
) {
if (message.type == "image") {
return <img src={message.content} />;
}
let parsedContentItems;
if (message.event.kind == NostrKind.Group_Message) {
return <p>{message.content}</p>;
parsedContentItems = parseContent(message.content);
} else {
parsedContentItems = message.event.parsedContentItems;
}
const vnode = [];
let start = 0;
for (const item of message.event.parsedContentItems) {
for (const item of parsedContentItems) {
vnode.push(message.content.slice(start, item.start));
const itemStr = message.content.slice(item.start, item.end + 1);
switch (item.type) {
@ -486,9 +494,10 @@ export function ParseMessageContent(
case "npub":
{
if (authorProfile) {
const profile = profileGetter.getProfilesByPublicKey(item.pubkey);
vnode.push(
<ProfileCard
profileData={authorProfile}
profileData={profile ? profile.profile : undefined}
publicKey={item.pubkey}
emit={emit}
/>,
@ -511,7 +520,8 @@ export function ParseMessageContent(
vnode.push(itemStr);
break;
}
vnode.push(Card(event, authorProfile, emit));
const profile = profileGetter.getProfilesByPublicKey(event.publicKey);
vnode.push(Card(event, profile ? profile.profile : undefined, emit));
}
break;
case "tag":

View File

@ -225,6 +225,18 @@ Deno.test("inline parse", async (t) => {
end: 23,
}],
},
{
input:
"You have been invited to group npub1k9p03z0gqsz2dqvjrkp6337lq5tl9nzj4wx0sfrpjmje2ze8nyls424ds3",
output: [{
type: "npub",
pubkey: PublicKey.FromBech32(
"npub1k9p03z0gqsz2dqvjrkp6337lq5tl9nzj4wx0sfrpjmje2ze8nyls424ds3",
),
start: 31,
end: 93,
}],
},
];
for (const [i, test] of data.entries()) {
await t.step(test.input, () => {

View File

@ -1,7 +1,8 @@
import { PublicKey } from "../lib/nostr-ts/key.ts";
import { DirectedMessage_Event } from "../nostr.ts";
import { DirectedMessage_Event, Parsed_Event } from "../nostr.ts";
import { Nevent, NostrAddress, NostrProfile, NoteID } from "../lib/nostr-ts/nip19.ts";
import { NostrEvent, NostrKind } from "../lib/nostr-ts/nostr.ts";
import { NostrKind } from "../lib/nostr-ts/nostr.ts";
import { gm_Invitation } from "../features/gm.ts";
export function* parseContent(content: string) {
// URLs
@ -164,14 +165,22 @@ export type ContentItem = {
};
// Think of ChatMessage as an materialized view of NostrEvent
export interface ChatMessage {
readonly event: DirectedMessage_Event | NostrEvent<NostrKind.Group_Message>;
readonly author: PublicKey;
export type ChatMessage = {
readonly type: "image" | "text";
readonly event: DirectedMessage_Event | Parsed_Event<NostrKind.Group_Message>;
readonly author: PublicKey;
readonly created_at: Date;
readonly lamport: number | undefined;
readonly content: string;
}
} | {
readonly type: "gm_invitation";
readonly event: Parsed_Event<NostrKind.Group_Message>;
readonly invitation: gm_Invitation;
readonly author: PublicKey;
readonly created_at: Date;
readonly lamport: number | undefined;
readonly content: string;
};
export function urlIsImage(url: string) {
const trimmed = url.trim();

View File

@ -16,6 +16,7 @@ import { prepareEncryptedNostrEvent } from "../lib/nostr-ts/event.ts";
import { DirectMessageGetter } from "../UI/app_update.tsx";
import { parseDM } from "../database.ts";
import { ChatMessage } from "../UI/message.ts";
import { decodeInvitation, gmEventType } from "./gm.ts";
export async function sendDMandImages(args: {
sender: NostrAccountContext;
@ -162,31 +163,84 @@ export class DirectedMessageController implements DirectMessageGetter {
public readonly ctx: NostrAccountContext,
) {}
public readonly directed_messages = new Map<string, DirectedMessage_Event>();
public readonly directed_messages = new Map<string, ChatMessage>();
// get the direct messages between me and this pubkey
public getDirectMessages(pubkey: string) {
const events = [];
for (const event of this.directed_messages.values()) {
if (is_DM_between(event, this.ctx.publicKey.hex, pubkey)) {
events.push(event);
public getDirectMessages(pubkey: string): ChatMessage[] {
const messages = [];
for (const message of this.directed_messages.values()) {
if (is_DM_between(message.event, this.ctx.publicKey.hex, pubkey)) {
if (message.event.kind == NostrKind.Group_Message) {
console.log(message);
}
messages.push(message);
}
}
const messages = convertEventsToChatMessages(events.sort(compare));
messages.sort((a, b) => compare(a.event, b.event));
return messages;
}
async addEvent(event: Parsed_Event<NostrKind.DIRECT_MESSAGE>) {
const dmEvent = await parseDM(
event,
this.ctx,
event.parsedTags,
event.publicKey,
);
if (dmEvent instanceof Error) {
return dmEvent;
async addEvent(event: Parsed_Event<NostrKind.DIRECT_MESSAGE | NostrKind.Group_Message>) {
const kind = event.kind;
if (kind == NostrKind.Group_Message) {
console.log("dm add event", kind);
const gmEvent = { ...event, kind };
const type = gmEventType(this.ctx, gmEvent);
if (type == "gm_invitation") {
const invitation = await decodeInvitation(this.ctx, gmEvent);
if (invitation instanceof Error) {
return invitation;
}
console.log("dm add event", invitation);
this.directed_messages.set(gmEvent.id, {
type: "text", // todo: change to invitation
event: gmEvent,
author: gmEvent.publicKey,
content: `You have been invited to group ${invitation.groupAddr.bech32()}`,
created_at: new Date(gmEvent.created_at * 1000),
// invitation: invitation,
lamport: gmEvent.parsedTags.lamport_timestamp,
});
}
// else ignore
} else {
const dmEvent = await parseDM(
{
...event,
kind,
},
this.ctx,
event.parsedTags,
event.publicKey,
);
if (dmEvent instanceof Error) {
return dmEvent;
}
const isImage = dmEvent.parsedTags.image;
if (isImage) {
const imageBase64 = reassembleBase64ImageFromEvents([dmEvent]);
if (imageBase64 instanceof Error) {
return imageBase64;
}
this.directed_messages.set(event.id, {
event: dmEvent,
author: dmEvent.publicKey,
content: imageBase64,
type: "image",
created_at: new Date(dmEvent.created_at * 1000),
lamport: dmEvent.parsedTags.lamport_timestamp,
});
} else {
this.directed_messages.set(event.id, {
event: dmEvent,
author: dmEvent.publicKey,
content: dmEvent.decryptedContent,
type: "text",
created_at: new Date(dmEvent.created_at * 1000),
lamport: dmEvent.parsedTags.lamport_timestamp,
});
}
}
this.directed_messages.set(event.id, dmEvent);
}
}
@ -199,56 +253,3 @@ function is_DM_between(event: NostrEvent, myPubkey: string, theirPubKey: string)
return false;
}
}
export function convertEventsToChatMessages(
events: Iterable<DirectedMessage_Event>,
): ChatMessage[] {
const messages: ChatMessage[] = [];
const groups = groupImageEvents(events);
let pubKeys = Array.from(groups.values()).map((es) => es[0].pubkey);
let textEvents = groups.get(undefined);
if (textEvents === undefined) {
textEvents = [];
}
pubKeys = pubKeys.concat(textEvents.map((e) => e.pubkey));
groups.delete(undefined);
for (let i = 0; i < textEvents.length; i++) {
const pubkey = PublicKey.FromHex(textEvents[i].pubkey);
if (pubkey instanceof Error) {
throw new Error(textEvents[i].pubkey);
}
messages.push({
event: textEvents[i],
author: pubkey,
content: textEvents[i].decryptedContent,
type: "text",
created_at: new Date(textEvents[i].created_at * 1000),
lamport: getTags(textEvents[i]).lamport_timestamp,
});
}
for (const imageEvents of groups.values()) {
const imageBase64 = reassembleBase64ImageFromEvents(imageEvents);
if (imageBase64 instanceof Error) {
console.info(imageBase64.message);
continue;
}
const pubkey = PublicKey.FromHex(imageEvents[0].pubkey);
if (pubkey instanceof Error) {
throw new Error(imageEvents[0].pubkey);
}
messages.push({
event: imageEvents[0],
author: pubkey,
content: imageBase64,
type: "image",
created_at: new Date(imageEvents[0].created_at * 1000),
lamport: getTags(imageEvents[0]).lamport_timestamp,
});
}
return messages;
}

View File

@ -9,7 +9,7 @@ import { prepareEncryptedNostrEvent } from "../lib/nostr-ts/event.ts";
import { PrivateKey, PublicKey } from "../lib/nostr-ts/key.ts";
import { InMemoryAccountContext, NostrAccountContext, NostrEvent, NostrKind } from "../lib/nostr-ts/nostr.ts";
import { ConnectionPool } from "../lib/nostr-ts/relay.ts";
import { getTags } from "../nostr.ts";
import { getTags, Parsed_Event } from "../nostr.ts";
import { parseJSON, ProfileSyncer } from "./profile.ts";
export type GM_Types = "gm_creation" | "gm_message" | "gm_invitation";
@ -18,19 +18,19 @@ export type GroupMessage = {
event: NostrEvent<NostrKind.Group_Message>;
};
export type GroupChatCreation = {
export type gm_Creation = {
cipherKey: InMemoryAccountContext;
groupKey: InMemoryAccountContext;
};
export type GroupChatInvitation = {
export type gm_Invitation = {
cipherKey: InMemoryAccountContext;
groupAddr: PublicKey;
};
export class GroupMessageController implements GroupMessageGetter, GroupMessageListGetter {
created_groups = new Map<string, GroupChatCreation>();
invitations = new Map<string, GroupChatInvitation>();
created_groups = new Map<string, gm_Creation>();
invitations = new Map<string, gm_Invitation>();
messages = new Map<string, ChatMessage[]>();
resync_chan = new Channel<null>();
@ -65,7 +65,7 @@ export class GroupMessageController implements GroupMessageGetter, GroupMessageL
return msgs ? msgs : [];
}
async encodeCreationToNostrEvent(groupCreation: GroupChatCreation) {
async encodeCreationToNostrEvent(groupCreation: gm_Creation) {
const event = prepareEncryptedNostrEvent(this.ctx, {
encryptKey: this.ctx.publicKey,
kind: NostrKind.Group_Message,
@ -80,7 +80,7 @@ export class GroupMessageController implements GroupMessageGetter, GroupMessageL
}
createGroupChat() {
const groupChatCreation: GroupChatCreation = {
const groupChatCreation: gm_Creation = {
cipherKey: InMemoryAccountContext.New(PrivateKey.Generate()),
groupKey: InMemoryAccountContext.New(PrivateKey.Generate()),
};
@ -90,12 +90,8 @@ export class GroupMessageController implements GroupMessageGetter, GroupMessageL
return groupChatCreation;
}
async addEvent(event: NostrEvent<NostrKind.Group_Message>) {
const type = await eventType(this.ctx, event);
if (type instanceof Error) {
return type;
}
async addEvent(event: Parsed_Event<NostrKind.Group_Message>) {
const type = gmEventType(this.ctx, event);
if (type == "gm_creation") {
return await this.handleCreation(event);
} else if (type == "gm_message") {
@ -107,7 +103,7 @@ export class GroupMessageController implements GroupMessageGetter, GroupMessageL
}
}
async addEvents(...events: NostrEvent<NostrKind.Group_Message>[]) {
async addEvents(...events: Parsed_Event<NostrKind.Group_Message>[]) {
for (const e of events) {
const err = await this.addEvent(e);
if (err instanceof Error) {
@ -117,55 +113,16 @@ export class GroupMessageController implements GroupMessageGetter, GroupMessageL
}
async handleInvitation(event: NostrEvent<NostrKind.Group_Message>) {
const decryptedContent = await this.ctx.decrypt(event.pubkey, event.content);
if (decryptedContent instanceof Error) {
return decryptedContent;
const invitation = await decodeInvitation(this.ctx, event);
if (invitation instanceof Error) {
return invitation;
}
const json = parseJSON<unknown>(decryptedContent);
if (json instanceof Error) {
return json;
}
const author = PublicKey.FromHex(event.pubkey);
if (author instanceof Error) {
return author;
}
let message: {
type: string;
cipherKey: string;
groupAddr: string;
};
try {
message = z.object({
type: z.string(),
cipherKey: z.string(),
groupAddr: z.string(),
}).parse(json);
} catch (e) {
return e as Error;
}
// add invitations
const cipherKey = PrivateKey.FromBech32(message.cipherKey);
if (cipherKey instanceof Error) {
return cipherKey;
}
const groupAddr = PublicKey.FromBech32(message.groupAddr);
if (groupAddr instanceof Error) {
return groupAddr;
}
const invitation: GroupChatInvitation = {
cipherKey: InMemoryAccountContext.New(cipherKey),
groupAddr,
};
this.invitations.set(groupAddr.bech32(), invitation);
this.groupSyncer.add(groupAddr.hex);
this.profileSyncer.add(groupAddr.hex);
this.invitations.set(invitation.groupAddr.bech32(), invitation);
this.groupSyncer.add(invitation.groupAddr.hex);
this.profileSyncer.add(invitation.groupAddr.hex);
}
async handleMessage(event: NostrEvent<NostrKind.Group_Message>) {
async handleMessage(event: Parsed_Event<NostrKind.Group_Message>) {
const groupAddr = getTags(event).p[0];
const groupAddrPubkey = PublicKey.FromHex(groupAddr);
if (groupAddrPubkey instanceof Error) {
@ -206,12 +163,12 @@ export class GroupMessageController implements GroupMessageGetter, GroupMessageL
}
const chatMessage: ChatMessage = {
type: "text",
event: event,
author: author,
content: message.text,
created_at: new Date(event.created_at * 1000),
lamport: getTags(event).lamport_timestamp,
type: "text",
lamport: event.parsedTags.lamport_timestamp,
};
const messages = this.messages.get(groupAddr);
@ -319,10 +276,10 @@ function isCreation(event: NostrEvent<NostrKind.Group_Message>) {
return event.tags.length == 0;
}
async function eventType(
export function gmEventType(
ctx: NostrAccountContext,
event: NostrEvent<NostrKind.Group_Message>,
): Promise<GM_Types | Error> {
): GM_Types {
if (isCreation(event)) {
return "gm_creation";
}
@ -371,3 +328,50 @@ export class GroupChatSyncer {
}
}
}
export async function decodeInvitation(ctx: NostrAccountContext, event: NostrEvent<NostrKind.Group_Message>) {
const decryptedContent = await ctx.decrypt(event.pubkey, event.content);
if (decryptedContent instanceof Error) {
return decryptedContent;
}
const json = parseJSON<unknown>(decryptedContent);
if (json instanceof Error) {
return json;
}
const author = PublicKey.FromHex(event.pubkey);
if (author instanceof Error) {
return author;
}
let message: {
type: string;
cipherKey: string;
groupAddr: string;
};
try {
message = z.object({
type: z.string(),
cipherKey: z.string(),
groupAddr: z.string(),
}).parse(json);
} catch (e) {
return e as Error;
}
// add invitations
const cipherKey = PrivateKey.FromBech32(message.cipherKey);
if (cipherKey instanceof Error) {
return cipherKey;
}
const groupAddr = PublicKey.FromBech32(message.groupAddr);
if (groupAddr instanceof Error) {
return groupAddr;
}
const invitation: gm_Invitation = {
cipherKey: InMemoryAccountContext.New(cipherKey),
groupAddr,
};
return invitation;
}