blowater/app/UI/app_update.tsx

680 lines
24 KiB
TypeScript
Raw Normal View History

/** @jsx h */
import { h } from "https://esm.sh/preact@10.17.1";
2024-01-01 17:28:10 +00:00
import { Channel, closed, sleep } from "https://raw.githubusercontent.com/BlowaterNostr/csp/master/csp.ts";
import { prepareEncryptedNostrEvent, prepareNormalNostrEvent } from "../../libs/nostr.ts/event.ts";
import { PublicKey } from "../../libs/nostr.ts/key.ts";
import { NoteID } from "../../libs/nostr.ts/nip19.ts";
import { NostrAccountContext, NostrEvent, NostrKind } from "../../libs/nostr.ts/nostr.ts";
import { ConnectionPool } from "../../libs/nostr.ts/relay-pool.ts";
import { Datebase_View } from "../database.ts";
2023-10-15 22:39:21 +00:00
import { emitFunc, EventBus } from "../event-bus.ts";
2024-01-01 17:28:10 +00:00
import { DirectedMessageController, sendDMandImages } from "../features/dm.ts";
import { GroupMessageController } from "../features/gm.ts";
import { ProfileSyncer, saveProfile } from "../features/profile.ts";
import {
2023-09-12 14:51:27 +00:00
Encrypted_Event,
getTags,
Parsed_Event,
PinConversation,
Profile_Nostr_Event,
UnpinConversation,
} from "../nostr.ts";
2024-01-01 17:28:10 +00:00
import { LamportTime } from "../time.ts";
import { App } from "./app.tsx";
import { Model } from "./app_model.ts";
import { PopOverInputChannel } from "./components/popover.tsx";
2023-10-01 23:16:08 +00:00
import { OtherConfig } from "./config-other.ts";
2024-01-01 17:28:10 +00:00
import { DM_List } from "./conversation-list.ts";
import { ContactUpdate } from "./conversation-list.tsx";
import { CreateGroup, CreateGroupChat, StartCreateGroupChat } from "./create-group.tsx";
import { StartInvite } from "./dm.tsx";
import { EditGroup, StartEditGroupChatProfile } from "./edit-group.tsx";
2023-10-23 13:57:58 +00:00
import { SaveProfile } from "./edit-profile.tsx";
2024-01-01 17:28:10 +00:00
import { EditorEvent, EditorModel, new_DM_EditorModel, SendMessage } from "./editor.tsx";
import { EventDetail, EventDetailItem } from "./event-detail.tsx";
import { InviteUsersToGroup } from "./invite-button.tsx";
import { DirectMessagePanelUpdate } from "./message-panel.tsx";
import { ChatMessage } from "./message.ts";
import { InstallPrompt, NavigationUpdate } from "./nav.tsx";
import { notify } from "./notification.ts";
2023-11-21 07:20:39 +00:00
import { RelayDetail } from "./relay-detail.tsx";
2024-01-01 17:28:10 +00:00
import { Search } from "./search.tsx";
import { SearchUpdate, SelectConversation } from "./search_model.ts";
import { RelayConfigChange, ViewRelayDetail } from "./setting.tsx";
import { SignInEvent } from "./signIn.tsx";
2023-06-30 14:05:57 +00:00
export type UI_Interaction_Event =
| SearchUpdate
| ContactUpdate
| EditorEvent
| NavigationUpdate
| DirectMessagePanelUpdate
| BackToContactList
2023-10-23 13:57:58 +00:00
| SaveProfile
| PinConversation
| UnpinConversation
2023-07-27 08:51:53 +00:00
| SignInEvent
2023-09-22 16:27:36 +00:00
| RelayConfigChange
| CreateGroupChat
2023-10-04 15:07:40 +00:00
| StartCreateGroupChat
2023-10-07 17:15:27 +00:00
| StartEditGroupChatProfile
2023-10-19 03:44:43 +00:00
| StartInvite
2023-11-21 07:20:39 +00:00
| InviteUsersToGroup
| ViewRelayDetail;
2023-06-30 14:05:57 +00:00
type BackToContactList = {
type: "BackToContactList";
};
export type AppEventBus = EventBus<UI_Interaction_Event>;
/////////////////////
// UI Interfaction //
/////////////////////
2023-08-03 09:13:16 +00:00
export async function* UI_Interaction_Update(args: {
model: Model;
eventBus: AppEventBus;
dbView: Datebase_View;
2023-08-03 09:13:16 +00:00
pool: ConnectionPool;
popOver: PopOverInputChannel;
2023-11-25 13:26:03 +00:00
newNostrEventChannel: Channel<NostrEvent>;
2023-11-25 14:29:50 +00:00
lamport: LamportTime;
2023-11-27 13:35:01 +00:00
installPrompt: InstallPrompt;
2023-08-03 09:13:16 +00:00
}) {
2023-11-27 13:35:01 +00:00
const { model, dbView, eventBus, pool, installPrompt } = args;
const events = eventBus.onChange();
2023-06-30 14:05:57 +00:00
for await (const event of events) {
console.log(event);
2023-07-09 07:06:13 +00:00
switch (event.type) {
2023-11-09 14:43:10 +00:00
case "SignInEvent":
const ctx = event.ctx;
2023-07-09 07:06:13 +00:00
if (ctx) {
console.log("sign in as", ctx.publicKey.bech32());
2023-11-25 14:29:50 +00:00
const otherConfig = await OtherConfig.FromLocalStorage(
ctx,
args.newNostrEventChannel,
args.lamport,
);
const app = await App.Start({
database: dbView,
2023-10-01 23:16:08 +00:00
model,
ctx,
eventBus,
pool,
popOverInputChan: args.popOver,
2023-10-01 23:16:08 +00:00
otherConfig,
2023-11-25 14:29:50 +00:00
lamport: args.lamport,
2023-11-27 13:35:01 +00:00
installPrompt,
});
model.app = app;
2023-07-09 07:06:13 +00:00
} else {
console.error("failed to sign in");
}
yield model;
continue;
2023-07-09 07:06:13 +00:00
}
const app = model.app;
if (app == undefined) { // if not signed in
console.warn(event, "is not valid before signing");
console.warn("This could not happen!");
continue;
} // All events below are only valid after signning in
2023-06-30 14:05:57 +00:00
//
2023-06-30 14:05:57 +00:00
//
// Searchx
2023-06-30 14:05:57 +00:00
//
2023-07-30 06:39:53 +00:00
else if (event.type == "CancelPopOver") {
model.search.isSearching = false;
2023-06-30 14:05:57 +00:00
} else if (event.type == "StartSearch") {
model.search.isSearching = true;
const search = (
<Search
placeholder={"Search a user's public key or name"}
db={app.database}
emit={eventBus.emit}
/>
);
args.popOver.put({ children: search });
2023-06-30 14:05:57 +00:00
} //
//
2023-11-21 07:20:39 +00:00
// Setting
//
else if (event.type == "ViewRelayDetail") {
app.popOverInputChan.put({
children: <RelayDetail relayUrl={event.url} profileGetter={app.database} />,
});
} //
//
2023-06-30 14:05:57 +00:00
// Contacts
//
2023-09-23 21:33:30 +00:00
else if (event.type == "SelectConversation") {
2023-12-17 20:41:49 +00:00
model.navigationModel.activeNav = "DM";
model.search.isSearching = false;
model.rightPanelModel = {
2023-06-30 14:05:57 +00:00
show: false,
};
2023-10-07 20:40:18 +00:00
updateConversation(app.model, event.pubkey, event.isGroupChat);
2023-06-30 14:05:57 +00:00
if (!model.dm.focusedContent.get(event.pubkey.hex)) {
model.dm.focusedContent.set(event.pubkey.hex, event.pubkey);
2023-06-30 14:05:57 +00:00
}
app.popOverInputChan.put({ children: undefined });
2023-10-04 21:34:43 +00:00
app.model.dm.isGroupMessage = event.isGroupChat;
2023-12-22 13:03:59 +00:00
app.conversationLists.markRead(event.pubkey.hex, event.isGroupChat);
2023-06-30 14:05:57 +00:00
} else if (event.type == "BackToContactList") {
2023-10-07 20:40:18 +00:00
model.dm.currentEditor = undefined;
} else if (event.type == "PinConversation") {
2023-11-25 13:26:03 +00:00
const err1 = await app.otherConfig.addPin(event.pubkey);
if (err1 instanceof Error) {
console.error(err1);
continue;
}
} else if (event.type == "UnpinConversation") {
2023-11-25 13:26:03 +00:00
const err1 = await app.otherConfig.removePin(event.pubkey);
if (err1 instanceof Error) {
console.error(err1);
continue;
}
2023-06-30 14:05:57 +00:00
} //
//
// Editor
//
else if (event.type == "SendMessage") {
2023-12-04 18:14:26 +00:00
handle_SendMessage(
event,
app.ctx,
app.lamport,
pool,
2023-10-21 11:29:47 +00:00
app.model.dmEditors,
app.model.gmEditors,
app.database,
app.groupChatController,
2023-12-04 18:14:26 +00:00
).then((res) => {
if (res instanceof Error) {
console.error("update:SendMessage", res);
}
});
2023-06-30 14:05:57 +00:00
} else if (event.type == "UpdateMessageFiles") {
2023-10-21 11:29:47 +00:00
const editors = event.isGroupChat ? model.gmEditors : model.dmEditors;
const editor = editors.get(event.pubkey.hex);
if (editor) {
editor.files = event.files;
} else {
2023-10-21 11:29:47 +00:00
editors.set(event.pubkey.hex, {
files: event.files,
pubkey: event.pubkey,
text: "",
});
2023-06-30 14:05:57 +00:00
}
} else if (event.type == "UpdateEditorText") {
2023-10-21 11:29:47 +00:00
const editorMap = event.isGroupChat ? model.gmEditors : model.dmEditors;
const editor = editorMap.get(event.pubkey.hex);
if (editor) {
editor.text = event.text;
} else {
editorMap.set(event.pubkey.hex, {
files: [],
text: event.text,
pubkey: event.pubkey,
});
2023-06-30 14:05:57 +00:00
}
2023-10-07 20:40:18 +00:00
console.log(editor);
2023-06-30 14:05:57 +00:00
} //
//
2023-10-23 13:57:58 +00:00
// Profile
2023-06-30 14:05:57 +00:00
//
2023-10-23 13:57:58 +00:00
else if (event.type == "SaveProfile") {
2023-06-30 14:05:57 +00:00
await saveProfile(
event.profile,
2023-10-23 13:57:58 +00:00
event.ctx,
pool,
2023-06-30 14:05:57 +00:00
);
app.popOverInputChan.put({ children: undefined });
2023-06-30 14:05:57 +00:00
} //
//
// Navigation
//
else if (event.type == "ChangeNavigation") {
2023-11-02 13:10:16 +00:00
model.navigationModel.activeNav = event.id;
model.rightPanelModel = {
2023-06-30 14:05:57 +00:00
show: false,
};
} //
//
// DM
//
2023-10-19 03:44:43 +00:00
else if (event.type == "InviteUsersToGroup") {
for (const pubkey of event.usersPublicKey) {
const invitationEvent = await app.groupChatController.createInvitation(
event.groupPublicKey,
pubkey,
);
if (invitationEvent instanceof Error) {
console.error(invitationEvent);
continue;
}
const err = await pool.sendEvent(invitationEvent);
if (err instanceof Error) {
console.error(err);
continue;
}
}
} else if (event.type == "ToggleRightPanel") {
model.rightPanelModel.show = event.show;
2023-06-30 14:05:57 +00:00
} else if (event.type == "ViewThread") {
2023-09-27 20:28:55 +00:00
if (model.navigationModel.activeNav == "DM") {
2023-10-07 20:40:18 +00:00
if (model.dm.currentEditor) {
model.dm.focusedContent.set(
2023-10-07 20:40:18 +00:00
model.dm.currentEditor.pubkey.hex,
2023-06-30 14:05:57 +00:00
event.root,
);
}
}
model.rightPanelModel.show = true;
2023-06-30 14:05:57 +00:00
} else if (event.type == "ViewUserDetail") {
2023-10-24 09:03:12 +00:00
if (model.dm.currentEditor) {
const currentFocus = model.dm.focusedContent.get(model.dm.currentEditor.pubkey.hex);
if (
model.rightPanelModel.show == true &&
currentFocus instanceof PublicKey &&
currentFocus.hex == event.pubkey.hex &&
currentFocus.hex == model.dm.currentEditor.pubkey.hex
) {
model.rightPanelModel.show = false;
} else {
model.dm.focusedContent.set(
2023-10-07 20:40:18 +00:00
model.dm.currentEditor.pubkey.hex,
2023-06-30 14:05:57 +00:00
event.pubkey,
);
2023-10-24 09:03:12 +00:00
model.rightPanelModel.show = true;
2023-06-30 14:05:57 +00:00
}
}
} else if (event.type == "OpenNote") {
open(`https://nostrapp.link/#${NoteID.FromHex(event.event.id).bech32()}?select=true`);
} else if (event.type == "StartCreateGroupChat") {
app.popOverInputChan.put({
children: <CreateGroup emit={eventBus.emit} />,
});
} else if (event.type == "CreateGroupChat") {
const profileData = event.profileData;
const groupCreation = app.groupChatController.createGroupChat();
const creationEvent = await app.groupChatController.encodeCreationToNostrEvent(groupCreation);
2023-10-03 15:15:45 +00:00
if (creationEvent instanceof Error) {
console.error(creationEvent);
continue;
}
2023-10-03 15:15:45 +00:00
const err = await pool.sendEvent(creationEvent);
if (err instanceof Error) {
console.error(err);
continue;
}
const profileEvent = await prepareNormalNostrEvent(
groupCreation.groupKey,
2023-10-26 08:42:04 +00:00
{
kind: NostrKind.META_DATA,
content: JSON.stringify(profileData),
},
);
const err2 = pool.sendEvent(profileEvent);
if (err2 instanceof Error) {
console.error(err2);
continue;
}
app.popOverInputChan.put({ children: undefined });
app.profileSyncer.add(groupCreation.groupKey.publicKey.hex);
2023-10-04 15:07:40 +00:00
} else if (event.type == "StartEditGroupChatProfile") {
app.popOverInputChan.put({
children: (
<EditGroup
emit={eventBus.emit}
ctx={event.ctx}
2023-10-09 13:55:44 +00:00
profileGetter={app.database}
2023-10-04 15:07:40 +00:00
/>
),
});
2023-10-07 17:15:27 +00:00
} else if (event.type == "StartInvite") {
app.popOverInputChan.put({
children: <div></div>,
});
} else if (event.type == "RelayConfigChange") {
2023-11-15 13:21:45 +00:00
const e = await prepareEncryptedNostrEvent(app.ctx, {
kind: NostrKind.Custom_App_Data,
encryptKey: app.ctx.publicKey,
content: JSON.stringify(event),
tags: [],
});
if (e instanceof Error) {
2023-11-15 13:21:45 +00:00
console.error(e);
continue;
}
{
const err = await pool.sendEvent(e);
if (err instanceof Error) {
console.error(err);
continue;
}
}
} else if (event.type == "ViewEventDetail") {
const nostrEvent = event.message.event;
const eventID = nostrEvent.id;
const eventIDBech32 = NoteID.FromString(nostrEvent.id).bech32();
const authorPubkey = event.message.author;
const content = nostrEvent.content;
const originalEventRaw = JSON.stringify(
{
content: nostrEvent.content,
created_at: nostrEvent.created_at,
kind: nostrEvent.kind,
tags: nostrEvent.tags,
pubkey: nostrEvent.pubkey,
id: nostrEvent.id,
sig: nostrEvent.sig,
},
null,
4,
);
const items: EventDetailItem[] = [
{
title: "Event ID",
fields: [
eventID,
eventIDBech32,
],
},
{
title: "Author",
fields: [
authorPubkey.hex,
authorPubkey.bech32(),
],
},
{
title: "Relays",
fields: Array.from(app.database.getRelayRecord(nostrEvent.id)),
},
{
title: "Content",
fields: [
content,
2023-10-21 11:29:47 +00:00
event.message.content,
originalEventRaw,
],
},
];
app.popOverInputChan.put({
children: (
<EventDetail
items={items}
/>
),
});
2023-06-30 14:05:57 +00:00
}
yield model;
2023-06-30 14:05:57 +00:00
}
}
export type DirectMessageGetter = ChatMessagesGetter & {
getDirectMessageStream(publicKey: string): Channel<ChatMessage>;
};
export type ChatMessagesGetter = {
getChatMessages(publicKey: string): ChatMessage[];
};
2023-06-30 14:05:57 +00:00
export function updateConversation(
model: Model,
targetPublicKey: PublicKey,
2023-10-07 20:40:18 +00:00
isGroupChat: boolean,
2023-06-30 14:05:57 +00:00
) {
2023-10-21 11:29:47 +00:00
const editorMap = isGroupChat ? model.gmEditors : model.dmEditors;
let editor = editorMap.get(targetPublicKey.hex);
2023-06-30 14:05:57 +00:00
// If this conversation is new
2023-10-07 20:40:18 +00:00
if (editor == undefined) {
editor = {
pubkey: targetPublicKey,
2023-06-30 14:05:57 +00:00
files: [],
text: "",
2023-10-07 20:40:18 +00:00
};
editorMap.set(targetPublicKey.hex, editor);
2023-06-30 14:05:57 +00:00
}
2023-10-07 20:40:18 +00:00
model.dm.currentEditor = editor;
2023-06-30 14:05:57 +00:00
}
//////////////
// Database //
//////////////
export async function* Database_Update(
ctx: NostrAccountContext,
database: Datebase_View,
2023-06-30 14:05:57 +00:00
model: Model,
2023-10-01 20:59:07 +00:00
profileSyncer: ProfileSyncer,
2023-06-30 14:05:57 +00:00
lamport: LamportTime,
2023-10-15 22:39:21 +00:00
convoLists: DM_List,
groupController: GroupMessageController,
dmController: DirectedMessageController,
2023-10-15 22:39:21 +00:00
emit: emitFunc<SelectConversation>,
2023-11-25 13:26:03 +00:00
args: {
otherConfig: OtherConfig;
},
2023-06-30 14:05:57 +00:00
) {
const changes = database.subscribe();
2023-06-30 14:05:57 +00:00
while (true) {
2024-01-01 17:28:10 +00:00
await sleep(333);
2023-06-30 14:05:57 +00:00
await changes.ready();
const changes_events: (Encrypted_Event | Profile_Nostr_Event | Parsed_Event)[] = [];
2023-06-30 14:05:57 +00:00
while (true) {
if (!changes.isReadyToPop()) {
break;
}
const e = await changes.pop();
2024-01-01 17:28:10 +00:00
if (e == closed) {
2023-06-30 14:05:57 +00:00
console.error("unreachable: db changes channel should never close");
break;
}
changes_events.push(e);
}
profileSyncer.add(...changes_events.map((e) => e.pubkey));
2023-12-22 13:03:59 +00:00
convoLists.addEvents(changes_events, true);
2023-06-30 14:05:57 +00:00
for (let e of changes_events) {
const t = getTags(e).lamport_timestamp;
if (t) {
lamport.set(t);
}
if (e.kind == NostrKind.META_DATA || e.kind == NostrKind.DIRECT_MESSAGE) {
for (const contact of convoLists.convoSummaries.values()) {
2023-10-21 11:29:47 +00:00
const editor = model.dmEditors.get(contact.pubkey.hex);
2023-06-30 14:05:57 +00:00
if (editor == null) { // a stranger sends a message
const pubkey = PublicKey.FromHex(contact.pubkey.hex);
if (pubkey instanceof Error) {
throw pubkey; // impossible
}
2023-10-21 11:29:47 +00:00
model.dmEditors.set(
2023-06-30 14:05:57 +00:00
contact.pubkey.hex,
new_DM_EditorModel(
2023-06-30 14:05:57 +00:00
pubkey,
),
2023-06-30 14:05:57 +00:00
);
}
}
2023-10-07 20:40:18 +00:00
if (model.dm.currentEditor) {
2023-06-30 14:05:57 +00:00
updateConversation(
model,
2023-10-07 20:40:18 +00:00
model.dm.currentEditor.pubkey,
false,
2023-06-30 14:05:57 +00:00
);
}
if (e.kind == NostrKind.META_DATA) {
// my profile update
if (ctx && e.pubkey == ctx.publicKey.hex) {
const newProfile = database.getProfilesByPublicKey(ctx.publicKey);
2023-06-30 14:05:57 +00:00
if (newProfile == undefined) {
throw new Error("impossible");
}
2023-07-14 10:59:25 +00:00
model.myProfile = newProfile.profile;
2023-06-30 14:05:57 +00:00
}
} else if (e.kind == NostrKind.DIRECT_MESSAGE) {
const err = await dmController.addEvent({
...e,
kind: e.kind,
});
if (err instanceof Error) {
console.error(err);
}
2023-06-30 14:05:57 +00:00
}
} else if (e.kind == NostrKind.Group_Message) {
{
const err = await groupController.addEvent({
...e,
kind: e.kind,
});
if (err instanceof Error) {
console.error(err, e);
await database.remove(e.id);
}
}
{
const err = await dmController.addEvent({
...e,
kind: e.kind,
});
if (err instanceof Error) {
console.error(err);
await database.remove(e.id);
}
}
2023-11-25 14:29:50 +00:00
} else if (e.kind == NostrKind.Encrypted_Custom_App_Data) {
2023-11-25 13:26:03 +00:00
console.log(e);
const err = await args.otherConfig.addEvent(e);
if (err instanceof Error) {
console.error(err);
}
2023-06-30 14:05:57 +00:00
}
2023-10-15 22:39:21 +00:00
// notification should be moved to after domain objects
{
const author = database.getProfilesByPublicKey(e.publicKey)
?.profile;
if (e.pubkey != ctx.publicKey.hex && e.parsedTags.p.includes(ctx.publicKey.hex)) {
notify(
author?.name ? author.name : "",
"new message",
author?.picture ? author.picture : "",
() => {
if (e.kind == NostrKind.DIRECT_MESSAGE) {
const k = PublicKey.FromHex(e.pubkey);
if (k instanceof Error) {
console.error(k);
return;
}
emit({
type: "SelectConversation",
pubkey: k,
isGroupChat: false,
});
} else if (e.kind == NostrKind.Group_Message) {
const k = PublicKey.FromHex(e.pubkey);
if (k instanceof Error) {
console.error(k);
return;
}
emit({
type: "SelectConversation",
pubkey: k,
isGroupChat: true,
});
} else if (e.kind == NostrKind.TEXT_NOTE) {
// todo
// open the default kind 1 app
} else {
// todo
// handle other types
}
},
);
}
}
2023-06-30 14:05:57 +00:00
}
yield model;
}
}
export async function handle_SendMessage(
event: SendMessage,
ctx: NostrAccountContext,
lamport: LamportTime,
pool: ConnectionPool,
dmEditors: Map<string, EditorModel>,
gmEditors: Map<string, EditorModel>,
db: Datebase_View,
groupControl: GroupMessageController,
) {
if (event.isGroupChat) {
2023-10-26 02:51:23 +00:00
const textEvent = await groupControl.prepareGroupMessageEvent(
event.pubkey,
event.text,
);
if (textEvent instanceof Error) {
return textEvent;
}
2023-10-26 02:51:23 +00:00
const err = await pool.sendEvent(textEvent);
if (err instanceof Error) {
return err;
}
2023-10-26 02:51:23 +00:00
for (const blob of event.files) {
const imageEvent = await groupControl.prepareGroupMessageEvent(
event.pubkey,
blob,
);
if (imageEvent instanceof Error) {
return imageEvent;
}
const err = await pool.sendEvent(imageEvent);
if (err instanceof Error) {
return err;
}
}
const editor = gmEditors.get(event.pubkey.hex);
if (editor) {
editor.files = [];
editor.text = "";
}
} else {
const events = await sendDMandImages({
sender: ctx,
receiverPublicKey: event.pubkey,
message: event.text,
files: event.files,
lamport_timestamp: lamport.now(),
pool,
tags: [],
});
if (events instanceof Error) {
return events;
}
{
// clearing the editor before sending the message to relays
// so that even if the sending is awaiting, the UI will clear
const editor = dmEditors.get(event.pubkey.hex);
if (editor) {
editor.files = [];
editor.text = "";
}
}
for (const eventSent of events) {
const err = await db.addEvent(eventSent);
if (err instanceof Error) {
console.error(err);
}
}
}
}