blowater/app/UI/app.tsx
2024-03-20 16:37:23 +08:00

544 lines
19 KiB
TypeScript

/** @jsx h */
import { h, render, VNode } from "https://esm.sh/preact@10.17.1";
import { Channel } from "https://raw.githubusercontent.com/BlowaterNostr/csp/master/csp.ts";
import { InvalidKey, PublicKey } from "../../libs/nostr.ts/key.ts";
import { NostrAccountContext, NostrEvent, NostrKind } from "../../libs/nostr.ts/nostr.ts";
import { ConnectionPool } from "../../libs/nostr.ts/relay-pool.ts";
import { Database_View } from "../database.ts";
import { EventBus } from "../event-bus.ts";
import { DirectedMessageController, getAllEncryptedMessagesOf, InvalidEvent } from "../features/dm.ts";
import { About } from "./about.tsx";
import { initialModel, Model } from "./app_model.ts";
import { AppEventBus, Database_Update, UI_Interaction_Event, UI_Interaction_Update } from "./app_update.tsx";
import { Popover, PopOverInputChannel } from "./components/popover.tsx";
import { OtherConfig } from "./config-other.ts";
import { DM_List } from "./conversation-list.ts";
import { DexieDatabase } from "./dexie-db.ts";
import { DirectMessageContainer } from "./dm.tsx";
import { EditProfile } from "./edit-profile.tsx";
import { RelayConfig } from "./relay-config.ts";
import { ProfileGetter } from "./search.tsx";
import { Setting } from "./setting.tsx";
import { getCurrentSignInCtx, getSignInState, setSignInState } from "./sign-in.ts";
import { SecondaryBackgroundColor } from "./style/colors.ts";
import { LamportTime } from "../time.ts";
import { InstallPrompt, NavBar } from "./nav.tsx";
import { Component } from "https://esm.sh/preact@10.17.1";
import { SingleRelayConnection } from "../../libs/nostr.ts/relay-single.ts";
import { PublicMessageContainer } from "./public-message-container.tsx";
import { ChatMessage } from "./message.ts";
import { filter, forever, map } from "./_helper.ts";
import { RightPanel } from "./components/right-panel.tsx";
import { ComponentChildren } from "https://esm.sh/preact@10.17.1";
import { SignIn } from "./sign-in.tsx";
import { getTags, Parsed_Event } from "../nostr.ts";
import { Toast } from "./components/toast.tsx";
import { ToastChannel } from "./components/toast.tsx";
import { RightPanelChannel } from "./components/right-panel.tsx";
export async function Start(database: DexieDatabase) {
console.log("Start the application");
const installPrompt: InstallPrompt = {
event: undefined,
};
window.addEventListener("beforeinstallprompt", async (event) => {
event.preventDefault();
installPrompt.event = event;
});
const lamport = new LamportTime();
const model = initialModel();
const eventBus = new EventBus<UI_Interaction_Event>();
const pool = new ConnectionPool();
const popOverInputChan: PopOverInputChannel = new Channel();
const rightPanelInputChan: RightPanelChannel = new Channel();
const toastInputChan: ToastChannel = new Channel();
const dbView = await Database_View.New(database, database, database);
const newNostrEventChannel = new Channel<NostrEvent>();
(async () => {
for await (const event of newNostrEventChannel) {
const err = await pool.sendEvent(event);
if (err instanceof Error) {
console.error(err);
}
}
})();
{
for (;;) {
if (getSignInState() === "none") {
break;
}
const ctx = await getCurrentSignInCtx();
if (ctx instanceof Error) {
console.error(ctx);
break;
} else if (ctx) {
const otherConfig = await OtherConfig.FromLocalStorage(ctx, newNostrEventChannel, lamport);
const app = await App.Start({
database: dbView,
model,
ctx,
eventBus,
pool,
popOverInputChan,
rightPanelInputChan,
otherConfig,
lamport,
installPrompt,
toastInputChan,
});
model.app = app;
break;
}
}
}
/* first render */ render(
<AppComponent
eventBus={eventBus}
model={model}
pool={pool}
popOverInputChan={popOverInputChan}
rightPanelInputChan={rightPanelInputChan}
installPrompt={installPrompt}
toastInputChan={toastInputChan}
/>,
document.body,
);
for await (
let _ of UI_Interaction_Update({
model,
eventBus,
dbView: dbView,
pool,
popOver: popOverInputChan,
rightPanel: rightPanelInputChan,
newNostrEventChannel: newNostrEventChannel,
lamport,
installPrompt,
toastInputChan: toastInputChan,
})
) {
const t = Date.now();
{
render(
<AppComponent
eventBus={eventBus}
model={model}
pool={pool}
popOverInputChan={popOverInputChan}
rightPanelInputChan={rightPanelInputChan}
installPrompt={installPrompt}
toastInputChan={toastInputChan}
/>,
document.body,
);
}
console.log("UI_Interaction_Update render:", Date.now() - t);
}
}
export class App {
private constructor(
public readonly database: Database_View,
public readonly model: Model,
public readonly ctx: NostrAccountContext,
public readonly eventBus: EventBus<UI_Interaction_Event>,
public readonly pool: ConnectionPool,
public readonly popOverInputChan: PopOverInputChannel,
public readonly rightPanelInputChan: RightPanelChannel,
public readonly otherConfig: OtherConfig,
public readonly conversationLists: DM_List,
public readonly relayConfig: RelayConfig,
public readonly lamport: LamportTime,
public readonly dmController: DirectedMessageController,
public readonly toastInputChan: ToastChannel,
) {}
static async Start(args: {
database: Database_View;
model: Model;
ctx: NostrAccountContext;
eventBus: EventBus<UI_Interaction_Event>;
pool: ConnectionPool;
popOverInputChan: PopOverInputChannel;
rightPanelInputChan: RightPanelChannel;
otherConfig: OtherConfig;
lamport: LamportTime;
installPrompt: InstallPrompt;
toastInputChan: ToastChannel;
}) {
const all_events = Array.from(args.database.getAllEvents());
args.lamport.fromEvents(all_events);
// init relay config
const relayConfig = await RelayConfig.FromLocalStorage({
ctx: args.ctx,
relayPool: args.pool,
});
console.log(relayConfig.getRelayURLs());
// init conversation list
const conversationLists = new DM_List(args.ctx);
const err = conversationLists.addEvents(all_events, false);
if (err instanceof InvalidEvent) {
console.error(err);
await args.database.remove(err.event.id);
}
const dmController = new DirectedMessageController(args.ctx);
(async () => {
// load DMs
for (const e of all_events) {
if (e.kind == NostrKind.DIRECT_MESSAGE) {
const error = await dmController.addEvent({
...e,
kind: e.kind,
});
if (error instanceof Error) {
console.error(error.message);
if (error instanceof InvalidKey) {
await args.database.remove(e.id);
}
}
} else {
continue;
}
}
})();
const app = new App(
args.database,
args.model,
args.ctx,
args.eventBus,
args.pool,
args.popOverInputChan,
args.rightPanelInputChan,
args.otherConfig,
conversationLists,
relayConfig,
args.lamport,
dmController,
args.toastInputChan,
);
await app.initApp(args.installPrompt);
return app;
}
private initApp = async (installPrompt: InstallPrompt) => {
console.log("App.initApp");
// Sync events
{
forever(sync_client_specific_data(this.pool, this.ctx, this.database));
forever(sync_dm_events(this.database, this.ctx, this.pool));
forever(sync_profile_events(this.database, this.pool));
forever(sync_kind_1(this.pool, this.database));
}
// Database
(async () => {
let i = 0;
for await (
let _ of Database_Update(
this.ctx,
this.database,
this.model,
this.lamport,
this.conversationLists,
this.dmController,
this.eventBus.emit,
{
otherConfig: this.otherConfig,
},
)
) {
const t = Date.now();
render(
<AppComponent
eventBus={this.eventBus}
model={this.model}
pool={this.pool}
popOverInputChan={this.popOverInputChan}
rightPanelInputChan={this.rightPanelInputChan}
installPrompt={installPrompt}
toastInputChan={this.toastInputChan}
/>,
document.body,
);
console.log(`Database_Update: render ${++i} times, ${Date.now() - t}`);
}
})();
};
logout = () => {
setSignInState("none");
window.location.reload();
};
}
type AppProps = {
model: Model;
eventBus: AppEventBus;
pool: ConnectionPool;
popOverInputChan: PopOverInputChannel;
rightPanelInputChan: RightPanelChannel;
toastInputChan: ToastChannel;
installPrompt: InstallPrompt;
};
export class AppComponent extends Component<AppProps> {
events = this.props.eventBus.onChange();
componentWillUnmount() {
this.events.close();
}
render(props: AppProps) {
const t = Date.now();
const model = props.model;
if (model.app == undefined) {
console.log("render sign in page");
return <SignIn emit={props.eventBus.emit} />;
}
const app = model.app;
const myAccountCtx = model.app.ctx;
let dmVNode;
let aboutNode;
if (
model.navigationModel.activeNav == "DM" ||
model.navigationModel.activeNav == "About"
) {
if (model.navigationModel.activeNav == "DM" && model.currentRelay) {
dmVNode = (
<DirectMessageContainer
{...model.dm}
bus={app.eventBus}
ctx={myAccountCtx}
getters={{
convoListRetriever: app.conversationLists,
messageGetter: app.dmController,
newMessageChecker: app.conversationLists,
relayRecordGetter: app.database,
pinListGetter: app.otherConfig,
profileGetter: app.database,
isUserBlocked: app.conversationLists.isUserBlocked,
getEventByID: app.database.getEventByID,
}}
userBlocker={app.conversationLists}
/>
);
}
if (model.navigationModel.activeNav == "About") {
aboutNode = About(app.eventBus.emit);
}
}
let publicNode: VNode | undefined;
if (model.navigationModel.activeNav == "Public" && model.currentRelay) {
publicNode = (
<PublicMessageContainer
ctx={myAccountCtx}
{...model.public}
getters={{
convoListRetriever: app.conversationLists,
newMessageChecker: app.conversationLists,
relayRecordGetter: app.database,
profileGetter: app.database,
isUserBlocked: app.conversationLists.isUserBlocked,
getEventByID: app.database.getEventByID,
}}
messages={Array.from(
map(
filter(
app.database.getAllEvents(),
(e) => {
if (e.kind != NostrKind.TEXT_NOTE) {
return false;
}
const relays = app.database.getRelayRecord(e.id);
return relays.has(model.currentRelay);
},
),
(e) => {
const msg: ChatMessage = {
author: e.publicKey,
content: e.content,
created_at: new Date(e.created_at * 1000),
event: e as Parsed_Event<NostrKind.TEXT_NOTE>,
lamport: getTags(e).lamport_timestamp,
type: "text",
};
return msg;
},
),
)}
relay={props.pool.getRelay(model.currentRelay) as SingleRelayConnection}
bus={app.eventBus}
/>
);
}
const final = (
<div class={`h-screen w-full flex`}>
<NavBar
publicKey={app.ctx.publicKey}
profile={app.database.getProfilesByPublicKey(myAccountCtx.publicKey)}
emit={app.eventBus.emit}
installPrompt={props.installPrompt}
currentRelay={model.currentRelay}
activeNav={model.navigationModel.activeNav}
pool={app.pool}
/>
<div
class={`h-full px-[3rem] sm:px-4 bg-[${SecondaryBackgroundColor}] flex-1 overflow-auto${
model.navigationModel.activeNav == "Profile" ? " block" : " hidden"
}`}
>
<div
class={`max-w-[35rem] h-full m-auto`}
>
<EditProfile
ctx={model.app.ctx}
profile={app.database.getProfilesByPublicKey(myAccountCtx.publicKey)?.profile}
emit={props.eventBus.emit}
/>
</div>
</div>
{publicNode}
{dmVNode}
{aboutNode}
{Setting({
show: model.navigationModel.activeNav == "Setting",
logout: app.logout,
relayConfig: app.relayConfig,
myAccountContext: myAccountCtx,
relayPool: props.pool,
emit: props.eventBus.emit,
})}
<Popover
inputChan={props.popOverInputChan}
/>
<RightPanel
inputChan={props.rightPanelInputChan}
/>
<Toast inputChan={props.toastInputChan} />
</div>
);
console.debug("AppComponent:end", Date.now() - t);
return final;
}
}
// todo: move to somewhere else
export function getFocusedContent(
focusedContent: PublicKey | NostrEvent | undefined,
profileGetter: ProfileGetter,
) {
if (focusedContent == undefined) {
return;
}
if (focusedContent instanceof PublicKey) {
const profileData = profileGetter.getProfilesByPublicKey(focusedContent)?.profile;
return {
type: "ProfileData" as "ProfileData",
data: profileData,
pubkey: focusedContent,
};
}
}
async function sync_dm_events(
database: Database_View,
ctx: NostrAccountContext,
pool: ConnectionPool,
) {
const messageStream = getAllEncryptedMessagesOf(
ctx.publicKey,
pool,
);
for await (const msg of messageStream) {
if (msg.res.type == "EVENT") {
const err = await database.addEvent(msg.res.event, msg.url);
if (err instanceof Error) {
console.log(err);
}
}
}
}
async function sync_profile_events(
database: Database_View,
pool: ConnectionPool,
) {
const messageStream = await pool.newSub("sync_profile_events", {
kinds: [NostrKind.META_DATA],
});
if (messageStream instanceof Error) {
return messageStream;
}
for await (const msg of messageStream.chan) {
if (msg.res.type == "EVENT") {
const err = await database.addEvent(msg.res.event, msg.url);
if (err instanceof Error) {
console.log(err);
}
}
}
}
const sync_kind_1 = async (pool: ConnectionPool, database: Database_View) => {
const stream = await pool.newSub("sync_kind_1", {
kinds: [NostrKind.TEXT_NOTE],
since: Math.floor(Date.now() / 1000) - 6 * 60 * 60, // 6 hours
});
if (stream instanceof Error) {
return stream;
}
for await (const msg of stream.chan) {
if (msg.res.type == "EOSE" || msg.res.type == "NOTICE") {
continue;
}
const ok = await database.addEvent(msg.res.event, msg.url);
if (ok instanceof Error) {
console.error(msg);
console.error(ok);
}
}
};
const sync_client_specific_data = async (
pool: ConnectionPool,
ctx: NostrAccountContext,
database: Database_View,
) => {
const stream = await pool.newSub(OtherConfig.name, {
authors: [ctx.publicKey.hex],
kinds: [NostrKind.Encrypted_Custom_App_Data],
});
if (stream instanceof Error) {
throw stream; // crash the app
}
for await (const msg of stream.chan) {
if (msg.res.type == "EOSE" || msg.res.type == "NOTICE") {
continue;
}
const ok = await database.addEvent(msg.res.event, msg.url);
if (ok instanceof Error) {
console.error(msg.res.event);
console.error(ok);
}
}
};