/** @jsx h */ import { Component, ComponentChildren, createRef, Fragment, h, VNode } from "https://esm.sh/preact@10.11.3"; import { tw } from "https://esm.sh/twind@0.16.16"; import { Editor, EditorEvent, EditorModel } from "./editor.tsx"; import { CloseIcon, LeftArrowIcon, ReplyIcon } from "./icons/mod.tsx"; import { Avatar } from "./components/avatar.tsx"; import { DividerClass, IconButtonClass } from "./components/tw.ts"; import { sleep } from "https://raw.githubusercontent.com/BlowaterNostr/csp/master/csp.ts"; import { EventEmitter } from "../event-bus.ts"; import { ChatMessage, groupContinuousMessages, parseContent, sortMessage, urlIsImage } from "./message.ts"; import { PublicKey } from "https://raw.githubusercontent.com/BlowaterNostr/nostr.ts/main/key.ts"; import { NostrEvent, NostrKind, } from "https://raw.githubusercontent.com/BlowaterNostr/nostr.ts/main/nostr.ts"; import { PinContact, UnpinContact } from "../nostr.ts"; import { getProfileEvent, ProfileData } from "../features/profile.ts"; import { MessageThread } from "./dm.tsx"; import { UserDetail } from "./user-detail.tsx"; import { MessageThreadPanel } from "./message-thread-panel.tsx"; import { Database } from "../database.ts"; import { DividerBackgroundColor, HoverButtonBackgroudColor, PrimaryBackgroundColor, PrimaryTextColor, } from "./style/colors.ts"; import { ProfilesSyncer } from "./contact-list.ts"; import { NoteID } from "https://raw.githubusercontent.com/BlowaterNostr/nostr.ts/main/nip19.ts"; import { EventSyncer } from "./event_syncer.ts"; interface DirectMessagePanelProps { myPublicKey: PublicKey; editorModel: EditorModel; messages: MessageThread[]; focusedContent: { type: "MessageThread"; data: MessageThread; editor: EditorModel; } | { type: "ProfileData"; data?: ProfileData; pubkey: PublicKey; } | undefined; rightPanelModel: RightPanelModel; db: Database; eventEmitter: EventEmitter< EditorEvent | DirectMessagePanelUpdate | PinContact | UnpinContact >; profilesSyncer: ProfilesSyncer; eventSyncer: EventSyncer; } export type RightPanelModel = { show: boolean; }; export type DirectMessagePanelUpdate = | { type: "ToggleRightPanel"; show: boolean; } | ViewThread | ViewUserDetail; export type ViewThread = { type: "ViewThread"; root: NostrEvent; }; export type ViewUserDetail = { type: "ViewUserDetail"; pubkey: PublicKey; }; export function MessagePanel(props: DirectMessagePanelProps) { const t = Date.now(); let placeholder = "Post your thoughts"; if (props.editorModel.target.kind == NostrKind.DIRECT_MESSAGE) { placeholder = `Message @${ props.editorModel.target.receiver.name || props.editorModel.target.receiver.pubkey.bech32() }`; } let rightPanel; if (props.rightPanelModel.show) { let rightPanelChildren: h.JSX.Element | undefined; if (props.focusedContent) { if (props.focusedContent.type == "MessageThread") { rightPanelChildren = ( ); } else if (props.focusedContent.type == "ProfileData") { rightPanelChildren = ( ); } } rightPanel = ( {rightPanelChildren} ); } let vnode = (
{ } { }
{!props.rightPanelModel.show ? ( ) : undefined} {rightPanel}
); console.log("DirectMessagePanel:end", Date.now() - t); return vnode; } interface MessageListProps { myPublicKey: PublicKey; threads: MessageThread[]; db: Database; eventEmitter: EventEmitter; profilesSyncer: ProfilesSyncer; eventSyncer: EventSyncer; } interface MessageListState { currentRenderCount: number; } const ItemsOfPerPage = 100; export class MessageList extends Component { constructor(public props: MessageListProps) { super(); } messagesULElement = createRef(); state = { currentRenderCount: ItemsOfPerPage, }; jitter = new JitterPrevention(100); componentWillReceiveProps() { this.setState({ currentRenderCount: ItemsOfPerPage, }); } onScroll = async (e: h.JSX.TargetedUIEvent) => { if ( e.currentTarget.scrollHeight - e.currentTarget.offsetHeight + e.currentTarget.scrollTop < 1000 ) { const ok = await this.jitter.shouldExecute(); if (!ok || this.state.currentRenderCount >= this.props.threads.length) { return; } this.setState({ currentRenderCount: Math.min( this.state.currentRenderCount + ItemsOfPerPage, this.props.threads.length, ), }); } }; sortAndSliceMessage = () => { return sortMessage(this.props.threads) .slice( 0, this.state.currentRenderCount, ); }; render() { const t = Date.now(); const groups = groupContinuousMessages(this.sortAndSliceMessage(), (pre, cur) => { const sameAuthor = pre.root.event.pubkey == cur.root.event.pubkey; const _66sec = Math.abs(cur.root.created_at.getTime() - pre.root.created_at.getTime()) < 1000 * 60; return sameAuthor && _66sec; }); console.log("MessageList:groupContinuousMessages", Date.now() - t); const messageBoxGroups = []; for (const threads of groups) { messageBoxGroups.push( MessageBoxGroup({ messageGroup: threads.map((thread) => { return { msg: thread.root, replyCount: thread.replies.length, }; }), myPublicKey: this.props.myPublicKey, eventEmitter: this.props.eventEmitter, db: this.props.db, profilesSyncer: this.props.profilesSyncer, eventSyncer: this.props.eventSyncer, }), ); } console.log("MessageList:elements", Date.now() - t); const vNode = (
    {messageBoxGroups}
); console.log("MessageList:end", Date.now() - t); return vNode; } } function MessageBoxGroup(props: { messageGroup: { msg: ChatMessage; replyCount: number; }[]; myPublicKey: PublicKey; db: Database; eventEmitter: EventEmitter; profilesSyncer: ProfilesSyncer; eventSyncer: EventSyncer; }) { // const t = Date.now(); const vnode = (
    {props.messageGroup.reverse().map((msg, index) => { return (
  • {AvatarOrTime(msg.msg, index, props.eventEmitter)}
    {NameAndTime(msg.msg, index, props.myPublicKey)}
                                    {ParseMessageContent(msg.msg, props.db, props.profilesSyncer, props.eventSyncer, props.eventEmitter)}
                                
    {msg.replyCount > 0 ? (
    { props.eventEmitter.emit({ type: "ViewThread", root: msg.msg.event, }); }} > {msg.replyCount} replies
    ) : undefined}
  • ); })}
); // console.log("MessageBoxGroup", Date.now() - t); return vnode; } export function AvatarOrTime( message: ChatMessage, index: number, eventEmitter?: EventEmitter, ) { if (index === 0) { return ( { eventEmitter.emit({ type: "ViewUserDetail", pubkey: message.author.pubkey, }); } : undefined} /> ); } return (
); } export function NameAndTime(message: ChatMessage, index: number, myPublicKey: PublicKey) { if (index === 0) { return (

{message.author ? ( message.author.pubkey.hex === myPublicKey.hex ? "Me" : message.author.name || message.author.pubkey.bech32() ) : "no user meta"}

{message.created_at.toLocaleString()}

); } } export function ParseMessageContent( message: ChatMessage, db: Database, profilesSyncer: ProfilesSyncer, eventSyncer: EventSyncer, eventEmitter: EventEmitter, ) { if (message.type == "image") { return ; } const vnode = [

{message.content}

]; for (const item of parseContent(message.content)) { const itemStr = message.content.slice(item.start, item.end + 1); switch (item.type) { case "url": if (urlIsImage(itemStr)) { vnode.push(); } break; case "npub": const pubkey = PublicKey.FromBech32(itemStr); const profile = getProfileEvent(db, pubkey); if (profile) { vnode.push(ProfileCard(profile.content, pubkey, eventEmitter)); } else { profilesSyncer.add(pubkey.hex); } break; case "note": const note = NoteID.FromBech32(itemStr); if (note instanceof Error) { console.error(note); break; } const event = eventSyncer.syncEvent(note); if (event instanceof Promise) { break; } vnode.push(NoteCard(event.content)); break; case "tag": // todo break; } } return vnode; } function ProfileCard(profile: ProfileData, pubkey: PublicKey, eventEmitter: EventEmitter) { return (
{ eventEmitter.emit({ type: "ViewUserDetail", pubkey: pubkey, }); }} >

{profile.name || pubkey.bech32}

{profile.about}

); } function NoteCard(content: string) { return (
{content}
); } type RightPanelProps = { eventEmitter: EventEmitter; rightPanelModel: RightPanelModel; children: ComponentChildren; }; function RightPanel(props: RightPanelProps) { return (
{props.children}
); } class JitterPrevention { constructor(private duration: number) {} cancel: ((value: void) => void) | undefined; async shouldExecute(): Promise { if (this.cancel) { this.cancel(); this.cancel = undefined; return this.shouldExecute(); } const p = new Promise((resolve) => { this.cancel = resolve; }); const cancelled = await sleep(this.duration, p); return !cancelled; } }