/** @jsx h */
import { Component, ComponentChildren, createRef, h } from "https://esm.sh/preact@10.17.1";
import { tw } from "https://esm.sh/twind@0.16.16";
import { Editor, EditorEvent, EditorModel } from "./editor.tsx";
import { AboutIcon, CloseIcon, LeftArrowIcon, ReplyIcon } from "./icons/mod.tsx";
import { Avatar } from "./components/avatar.tsx";
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 { PublicKey } from "../lib/nostr-ts/key.ts";
import { NostrEvent, NostrKind } from "../lib/nostr-ts/nostr.ts";
import {
DirectedMessage_Event,
Parsed_Event,
PinConversation,
Profile_Nostr_Event,
Text_Note_Event,
UnpinConversation,
} from "../nostr.ts";
import { ProfileData, ProfileSyncer } from "../features/profile.ts";
import { MessageThread } from "./dm.tsx";
import { UserDetail } from "./user-detail.tsx";
import { LinkColor, PrimaryTextColor } from "./style/colors.ts";
import { EventSyncer } from "./event_syncer.ts";
import { ButtonGroup } from "./components/button-group.tsx";
import { ProfileCard } from "./profile-card.tsx";
import { NoteCard } from "./note-card.tsx";
import { ProfileGetter } from "./search.tsx";
export type RightPanelModel = {
show: boolean;
};
export type DirectMessagePanelUpdate =
| {
type: "ToggleRightPanel";
show: boolean;
}
| ViewThread
| ViewUserDetail
| ViewNoteThread
| {
type: "ViewEventDetail";
event: Text_Note_Event | DirectedMessage_Event;
};
export type ViewNoteThread = {
type: "ViewNoteThread";
event: Parsed_Event;
};
export type ViewThread = {
type: "ViewThread";
root: NostrEvent;
};
export type ViewUserDetail = {
type: "ViewUserDetail";
pubkey: PublicKey;
};
interface DirectMessagePanelProps {
myPublicKey: PublicKey;
isGroupChat: boolean;
editorModel: EditorModel;
messages: MessageThread[];
focusedContent: {
type: "ProfileData";
data?: ProfileData;
pubkey: PublicKey;
} | undefined;
rightPanelModel: RightPanelModel;
emit: emitFunc<
EditorEvent | DirectMessagePanelUpdate | PinConversation | UnpinConversation
>;
profilesSyncer: ProfileSyncer;
eventSyncer: EventSyncer;
profileGetter: ProfileGetter;
}
export class MessagePanel extends Component {
render() {
const props = this.props;
const t = Date.now();
let rightPanel;
if (props.rightPanelModel.show) {
let rightPanelChildren: h.JSX.Element | undefined;
if (props.focusedContent) {
if (props.focusedContent.type == "ProfileData") {
rightPanelChildren = (
);
}
}
rightPanel = (
{rightPanelChildren}
);
}
let vnode = (
{!props.rightPanelModel.show
? (
)
: undefined}
{rightPanel}
);
return vnode;
}
}
interface MessageListProps {
myPublicKey: PublicKey;
threads: MessageThread[];
emit: emitFunc;
profilesSyncer: ProfileSyncer;
eventSyncer: EventSyncer;
profileGetter: ProfileGetter;
}
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;
});
const messageBoxGroups = [];
let i = 0;
for (const threads of groups) {
messageBoxGroups.push(
MessageBoxGroup({
messageGroup: threads.map((thread) => {
i++;
return {
msg: thread.root,
replyCount: thread.replies.length,
};
}),
myPublicKey: this.props.myPublicKey,
emit: this.props.emit,
profilesSyncer: this.props.profilesSyncer,
eventSyncer: this.props.eventSyncer,
profileGetter: this.props.profileGetter,
}),
);
}
console.log(`MessageList:elements ${i}`, Date.now() - t);
const vNode = (
);
console.log("MessageList:end", Date.now() - t);
return vNode;
}
}
function MessageBoxGroup(props: {
messageGroup: {
msg: ChatMessage;
replyCount: number;
}[];
myPublicKey: PublicKey;
emit: emitFunc;
profilesSyncer: ProfileSyncer;
profileGetter: ProfileGetter;
eventSyncer: EventSyncer;
}) {
// const t = Date.now();
const messageGroups = props.messageGroup.reverse();
if (messageGroups.length == 0) {
return;
}
const first_group = messageGroups[0];
const rows = [];
rows.push(
{MessageActions(first_group.msg.event, props.emit)}
{
props.emit({
type: "ViewUserDetail",
pubkey: first_group.msg.event.publicKey,
});
}}
/>
{NameAndTime(
first_group.msg.event.publicKey,
props.profileGetter.getProfilesByPublicKey(first_group.msg.event.publicKey)
?.profile,
props.myPublicKey,
first_group.msg.created_at,
)}
{ParseMessageContent(
first_group.msg,
props.profilesSyncer,
props.profileGetter,
props.eventSyncer,
props.emit,
)}
{first_group.replyCount > 0
? (
{
props.emit({
type: "ViewThread",
root: first_group.msg.event,
});
}}
>
{first_group.replyCount} replies
)
: undefined}
,
);
for (let i = 1; i < messageGroups.length; i++) {
const msg = messageGroups[i];
rows.push(
{MessageActions(msg.msg.event, props.emit)}
{Time(msg.msg.created_at)}
{ParseMessageContent(
msg.msg,
props.profilesSyncer,
props.profileGetter,
props.eventSyncer,
props.emit,
)}
{msg.replyCount > 0
? (
{
props.emit({
type: "ViewThread",
root: msg.msg.event,
});
}}
>
{msg.replyCount} replies
)
: undefined}
,
);
}
const vnode = (
);
// console.log("MessageBoxGroup", Date.now() - t);
return vnode;
}
function MessageActions(
event: Text_Note_Event | DirectedMessage_Event,
emit: emitFunc,
) {
return (
);
}
export function Time(created_at: Date) {
return (
{created_at.toTimeString().slice(0, 5)}
);
}
export function NameAndTime(
author: PublicKey,
author_profile: ProfileData | undefined,
myPublicKey: PublicKey,
created_at: Date,
) {
let show = author.bech32();
if (author.hex == myPublicKey.hex) {
show = "Me";
} else if (author_profile?.name) {
show = author_profile.name;
}
return (
{show}
{created_at.toLocaleString()}
);
}
export function ParseMessageContent(
message: ChatMessage,
profilesSyncer: ProfileSyncer,
profileGetter: ProfileGetter,
eventSyncer: EventSyncer,
emit: emitFunc,
) {
if (message.type == "image") {
return ;
}
const vnode = [];
let start = 0;
for (const item of message.event.parsedContentItems) {
vnode.push(message.content.slice(start, item.start));
const itemStr = message.content.slice(item.start, item.end + 1);
switch (item.type) {
case "url":
{
if (urlIsImage(itemStr)) {
vnode.push();
} else {
vnode.push(
{itemStr}
,
);
}
}
break;
case "npub":
{
const userInfo = profileGetter.getProfilesByPublicKey(item.pubkey);
if (userInfo) {
const profile = userInfo.profile;
if (profile) {
vnode.push(
,
);
break;
} else {
profilesSyncer.add(item.pubkey.hex);
}
} else {
profilesSyncer.add(item.pubkey.hex);
}
vnode.push(
,
);
}
break;
case "note":
{
const event = eventSyncer.syncEvent(item.noteID);
if (
event instanceof Promise || event.kind == NostrKind.DIRECT_MESSAGE
) {
vnode.push(itemStr);
break;
}
vnode.push(Card(event, emit, profileGetter));
}
break;
case "tag":
// todo
break;
}
start = item.end + 1;
}
vnode.push(message.content.slice(start));
return vnode;
}
function Card(
event: Profile_Nostr_Event | Text_Note_Event,
emit: emitFunc,
profileGetter: ProfileGetter,
) {
switch (event.kind) {
case NostrKind.META_DATA:
return ;
case NostrKind.TEXT_NOTE:
const pubkey = PublicKey.FromHex(event.pubkey);
// @ts-ignore
const profile = profileGetter.getProfilesByPublicKey(pubkey)?.profile;
return ;
}
}
type RightPanelProps = {
emit: emitFunc;
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;
}
}