/** @jsx h */ import { Component, ComponentChildren, createRef, Fragment, h, RefObject } from "preact"; import { NoteID, PublicKey, robohash } from "@blowater/nostr-sdk"; import { RelayRecordGetter } from "../database.ts"; import { emitFunc } from "../event-bus.ts"; import { IconButtonClass } from "./components/tw.ts"; import { LeftArrowIcon } from "./icons/left-arrow-icon.tsx"; import { DirectMessagePanelUpdate, NameAndTime, ParseMessageContent, SendReaction, SyncEvent, Time, ViewUserDetail, } from "./message-panel.tsx"; import { ChatMessage, groupContinuousMessages, sortMessage } from "./message.ts"; import { SelectConversation } from "./search_model.ts"; import { sleep } from "@blowater/csp"; import { ProfileData } from "../features/profile.ts"; import { isMobile, setState } from "./_helper.ts"; import { Avatar } from "./components/avatar.tsx"; import { AboutIcon } from "./icons/about-icon.tsx"; import { BackgroundColor_MessagePanel } from "./style/colors.ts"; import { Parsed_Event } from "../nostr.ts"; import { ReplyIcon } from "./icons/reply-icon.tsx"; import { ChatMessagesGetter } from "./app_update.tsx"; import { NostrKind } from "@blowater/nostr-sdk"; import { func_GetProfileByPublicKey } from "./search.tsx"; import { DeleteIcon } from "./icons/delete-icon.tsx"; import { ThumbsUpIcon } from "./icons/thumbs-up-icon.tsx"; export type func_IsAdmin = (pubkey: string) => boolean; interface Props { myPublicKey: PublicKey; messages: ChatMessage[]; emit: emitFunc< | DirectMessagePanelUpdate | SelectConversation | SyncEvent | ViewUserDetail | ReplyToMessage | DeleteEvent | SendReaction >; getters: { messageGetter: ChatMessagesGetter; getProfileByPublicKey: func_GetProfileByPublicKey; relayRecordGetter: RelayRecordGetter; getEventByID: func_GetEventByID; isAdmin: func_IsAdmin | undefined; getReactionsByEventID: func_GetReactionsByEventID; }; } interface MessageListState { offset: number; } const ItemsOfPerPage = 50; export class MessageList extends Component { readonly messagesULElement = createRef(); state = { offset: 0, }; jitter = new JitterPrevention(100); async componentDidUpdate(previousProps: Readonly) { const newest = last(this.props.messages); const pre_newest = last(previousProps.messages); if ( newest && pre_newest && newest.author.hex == this.props.myPublicKey.hex && newest.event.id != pre_newest.event.id ) { await this.goToLastPage(); this.goToButtom(false); } } async componentDidMount() { const offset = this.props.messages.length - ItemsOfPerPage; await setState(this, { offset: offset <= 0 ? 0 : offset }); } render() { const messages_to_render = this.sortAndSliceMessage(); const groups = groupContinuousMessages(messages_to_render, (pre, cur) => { const sameAuthor = pre.event.pubkey == cur.event.pubkey; const _66sec = Math.abs(cur.created_at.getTime() - pre.created_at.getTime()) < 1000 * 60; return sameAuthor && _66sec && !isReply(cur.event); }); const messageBoxGroups = []; for (const messages of groups) { const profileEvent = this.props.getters.getProfileByPublicKey(messages[0].author, undefined); messageBoxGroups.push( MessageBoxGroup({ messages: messages, myPublicKey: this.props.myPublicKey, emit: this.props.emit, authorProfile: profileEvent ? profileEvent.profile : undefined, getters: this.props.getters, }), ); } return (
{MessageListView(this.goToButtom, this.messagesULElement, messageBoxGroups)}
); } sortAndSliceMessage = () => { return sortMessage(this.props.messages) .slice( this.state.offset, this.state.offset + ItemsOfPerPage, ); }; prePage = async () => { const offset = this.state.offset - ItemsOfPerPage / 2; if (offset > 0) { await setState(this, { offset }); } else { await setState(this, { offset: 0 }); } }; nextPage = async () => { const offset = this.state.offset + ItemsOfPerPage / 2; if (offset < this.props.messages.length) { await setState(this, { offset }); } else { await this.goToLastPage(); } }; goToButtom = (smooth: boolean) => { if (this.messagesULElement.current) { this.messagesULElement.current.scrollTo({ top: this.messagesULElement.current.scrollHeight, left: 0, behavior: smooth ? "smooth" : undefined, }); } }; goToLastPage = async () => { const newOffset = this.props.messages.length - ItemsOfPerPage / 2; await setState(this, { offset: newOffset > 0 ? newOffset : 0, }); console.log("goToLastPage", this.state.offset); }; } function MessageListView( goToButtom: (smooth: boolean) => void, messagesULElement: RefObject, messageBoxGroups: ComponentChildren, ) { return (
    {messageBoxGroups}
); } export class MessageList_V0 extends Component { readonly messagesULElement = createRef(); jitter = new JitterPrevention(100); async componentDidMount() { this.goToButtom(false); } componentDidUpdate(previousProps: Readonly): void { // todo: this is not a correct check of if new message is received // a better check is to see if the // current newest message is newer than previous newest message if (previousProps.messages.length < this.props.messages.length) { this.goToButtom(false); } } render() { const messages_to_render = this.sortAndSliceMessage(); const groups = groupContinuousMessages(messages_to_render, (pre, cur) => { const sameAuthor = pre.event.pubkey == cur.event.pubkey; const _66sec = Math.abs(cur.created_at.getTime() - pre.created_at.getTime()) < 1000 * 60; return sameAuthor && _66sec && !isReply(cur.event); }); const messageBoxGroups = []; for (const messages of groups) { const profileEvent = this.props.getters.getProfileByPublicKey(messages[0].author, undefined); messageBoxGroups.push( MessageBoxGroup({ messages: messages, myPublicKey: this.props.myPublicKey, emit: this.props.emit, authorProfile: profileEvent ? profileEvent.profile : undefined, getters: this.props.getters, }), ); } return MessageListView(this.goToButtom, this.messagesULElement, messageBoxGroups); } sortAndSliceMessage = () => { return sortMessage(this.props.messages); }; goToButtom = (smooth: boolean) => { if (this.messagesULElement.current) { this.messagesULElement.current.scrollTo({ top: this.messagesULElement.current.scrollHeight, left: 0, behavior: smooth ? "smooth" : undefined, }); } }; } 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; } } export type func_GetEventByID = ( id: string | NoteID, ) => Parsed_Event | undefined; export type func_GetReactionsByEventID = (id: string) => Set; function MessageBoxGroup(props: { authorProfile: ProfileData | undefined; messages: ChatMessage[]; myPublicKey: PublicKey; emit: emitFunc< | DirectMessagePanelUpdate | ViewUserDetail | SelectConversation | SyncEvent | ReplyToMessage | DeleteEvent | SendReaction >; getters: { messageGetter: ChatMessagesGetter; getProfileByPublicKey: func_GetProfileByPublicKey; relayRecordGetter: RelayRecordGetter; getEventByID: func_GetEventByID; isAdmin: func_IsAdmin | undefined; getReactionsByEventID: func_GetReactionsByEventID; }; }) { const first_message = props.messages[0]; const { myPublicKey } = props; const rows = []; rows.push(
  • {MessageActions({ isAdmin: props.getters.isAdmin, myPublicKey, message: first_message, emit: props.emit, })} {renderReply(first_message.event, props.getters, props.emit)}
    { props.emit({ type: "ViewUserDetail", pubkey: first_message.author, }); }} />
    {NameAndTime( first_message.author, props.authorProfile, props.myPublicKey, first_message.created_at, )}
                    {ParseMessageContent(
                       first_message,
                        props.emit,
                        props.getters,
                        )}
                        
  • , ); for (let i = 1; i < props.messages.length; i++) { const msg = props.messages[i]; rows.push(
  • {MessageActions({ isAdmin: props.getters.isAdmin, myPublicKey, message: msg, emit: props.emit, })} {Time(msg.created_at)}
                        {ParseMessageContent(msg, props.emit, props.getters)}
                        
  • , ); } const vnode = (
      {rows}
    ); return vnode; } export type ReplyToMessage = { type: "ReplyToMessage"; event: Parsed_Event; }; function MessageActions(args: { myPublicKey: PublicKey; message: ChatMessage; emit: emitFunc; isAdmin: func_IsAdmin | undefined; }) { const { myPublicKey, message, emit, isAdmin } = args; return (
    {(myPublicKey.hex === message.author.hex || (isAdmin && isAdmin(myPublicKey.hex))) && ([NostrKind.TEXT_NOTE, NostrKind.Long_Form].includes(message.event.kind)) && ( )}
    ); } export type DeleteEvent = { type: "DeleteEvent"; event: Parsed_Event; }; function last(array: Array): T | undefined { if (array.length == 0) { return undefined; } else { return array[array.length - 1]; } } function isReply(event: Parsed_Event) { return event.parsedTags.reply || event.parsedTags.root || event.parsedTags.e.length != 0; } function renderReply(event: Parsed_Event, getters: { messageGetter: ChatMessagesGetter; getEventByID: func_GetEventByID; getProfileByPublicKey: func_GetProfileByPublicKey; }, emit: emitFunc) { if (!isReply(event)) return; const replyEventId = event.parsedTags.reply?.[0] || event.parsedTags.root?.[0] || event.parsedTags.e[0]; const reply_to_event = getters.getEventByID(replyEventId); if (!reply_to_event) { return ; } let author = reply_to_event.publicKey.bech32(); let picture = robohash(reply_to_event.publicKey.hex); if (reply_to_event.pubkey) { const profile = getters.getProfileByPublicKey(reply_to_event.publicKey, undefined); if (profile) { author = profile.profile.name || profile.profile.display_name || reply_to_event?.publicKey.bech32(); picture = profile.profile.picture || robohash(reply_to_event.publicKey.hex); } } let content = reply_to_event.content; if (reply_to_event.kind === NostrKind.DIRECT_MESSAGE) { const message = getters.messageGetter.getMessageById(reply_to_event.id); if (message) content = message.content; } return ( ); } function ReplyTo( props: { unknown?: false; reply: { pubkey: PublicKey; content: string; name: string; picture: string; }; emit: emitFunc; } | { unknown: true; noteId: NoteID; }, ) { return (
    {props.unknown ? (
    {props.noteId.bech32()}
    ) : (
    props.emit({ type: "ViewUserDetail", pubkey: props.reply.pubkey, })} >
    {props.reply.content}
    )}
    ); } function Reactions( props: { myPublicKey: PublicKey; events: Set; }, ) { const reactions: Map = new Map(); for (const event of props.events) { let reaction = event.content; if (!reaction) continue; if (reaction === "+") reaction = "👍"; if (reaction === "-") reaction = "👎"; const pre = reactions.get(reaction); const isClicked = event.pubkey === props.myPublicKey.hex; if (pre) { reactions.set(reaction, { count: pre.count + 1, clicked: pre.clicked || isClicked, }); } else { reactions.set(reaction, { count: 1, clicked: isClicked, }); } } return ( reactions.size > 0 ? (
    {Array.from(reactions).map(([reaction, { count, clicked }]) => { return (
    {reaction}
    {count > 1 &&
    {` ${count}`}
    }
    ); })}
    ) : null ); }