blowater/app/UI/message-list.tsx
Bob 0c494506f5
profile setting per space (#488)
Co-authored-by: BlowaterNostr <blowater.nostr@proton.me>
2024-07-05 17:26:24 +08:00

655 lines
22 KiB
TypeScript

/** @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<Props, MessageListState> {
readonly messagesULElement = createRef<HTMLUListElement>();
state = {
offset: 0,
};
jitter = new JitterPrevention(100);
async componentDidUpdate(previousProps: Readonly<Props>) {
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 (
<div class="w-full flex flex-col overflow-auto">
<button class={`${IconButtonClass} shrink-0`} onClick={this.prePage}>
load earlier messages
</button>
{MessageListView(this.goToButtom, this.messagesULElement, messageBoxGroups)}
<button class={`${IconButtonClass} shrink-0`} onClick={this.nextPage}>
load more messages
</button>
</div>
);
}
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<HTMLUListElement>,
messageBoxGroups: ComponentChildren,
) {
return (
<div
class={`w-full overflow-hidden ${BackgroundColor_MessagePanel}`}
style={{
transform: "perspective(none)",
}}
>
<button
onClick={() => goToButtom(true)}
class={`${IconButtonClass} fixed z-10 bottom-8 right-4 h-10 w-10 rotate-[-90deg] bg-[#42464D] hover:bg-[#2F3136]`}
>
<LeftArrowIcon
class={`w-6 h-6`}
style={{
fill: "#F3F4EA",
}}
/>
</button>
<ul
class={`w-full h-full overflow-y-auto overflow-x-hidden py-9 mobile:py-2 px-2 mobile:px-0 flex flex-col`}
ref={messagesULElement}
>
{messageBoxGroups}
</ul>
</div>
);
}
export class MessageList_V0 extends Component<Props> {
readonly messagesULElement = createRef<HTMLUListElement>();
jitter = new JitterPrevention(100);
async componentDidMount() {
this.goToButtom(false);
}
componentDidUpdate(previousProps: Readonly<Props>): 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<boolean> {
if (this.cancel) {
this.cancel();
this.cancel = undefined;
return this.shouldExecute();
}
const p = new Promise<void>((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<Parsed_Event>;
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(
<li
class={`px-4 hover:bg-[#32353B] w-full max-w-full flex flex-col pr-8 mobile:pr-4 group relative ${
isMobile() ? "select-none" : ""
}`}
>
{MessageActions({
isAdmin: props.getters.isAdmin,
myPublicKey,
message: first_message,
emit: props.emit,
})}
{renderReply(first_message.event, props.getters, props.emit)}
<div class="flex items-start">
<Avatar
class={`h-8 w-8 mt-[0.45rem] mr-2`}
picture={props.authorProfile?.picture ||
robohash(first_message.author.hex)}
onClick={() => {
props.emit({
type: "ViewUserDetail",
pubkey: first_message.author,
});
}}
/>
<div
class={`flex-1`}
style={{
maxWidth: "calc(100% - 2.75rem)",
}}
>
{NameAndTime(
first_message.author,
props.authorProfile,
props.myPublicKey,
first_message.created_at,
)}
<pre
class={`text-[#DCDDDE] whitespace-pre-wrap break-words font-roboto text-sm`}
>
{ParseMessageContent(
first_message,
props.emit,
props.getters,
)}
</pre>
<Reactions
myPublicKey={props.myPublicKey}
events={props.getters.getReactionsByEventID(first_message.event.id)}
/>
</div>
</div>
</li>,
);
for (let i = 1; i < props.messages.length; i++) {
const msg = props.messages[i];
rows.push(
<li
class={`px-4 hover:bg-[#32353B] w-full max-w-full flex items-center pr-8 mobile:pr-4 group relative text-sm ${
isMobile() ? "select-none" : ""
}`}
>
{MessageActions({
isAdmin: props.getters.isAdmin,
myPublicKey,
message: msg,
emit: props.emit,
})}
{Time(msg.created_at)}
<div
className={`flex-1`}
style={{
maxWidth: "calc(100% - 2.75rem)",
}}
>
<pre
class={`text-[#DCDDDE] whitespace-pre-wrap break-words font-roboto`}
>
{ParseMessageContent(msg, props.emit, props.getters)}
</pre>
<Reactions
myPublicKey={props.myPublicKey}
events={props.getters.getReactionsByEventID(msg.event.id)}
/>
</div>
</li>,
);
}
const vnode = (
<ul class={`py-2`}>
{rows}
</ul>
);
return vnode;
}
export type ReplyToMessage = {
type: "ReplyToMessage";
event: Parsed_Event;
};
function MessageActions(args: {
myPublicKey: PublicKey;
message: ChatMessage;
emit: emitFunc<DirectMessagePanelUpdate | ReplyToMessage | DeleteEvent | SendReaction>;
isAdmin: func_IsAdmin | undefined;
}) {
const { myPublicKey, message, emit, isAdmin } = args;
return (
<div
class={`hidden
group-hover:flex
h-8
bg-[#313338] border-[#27292D] border border-solid rounded
hover:drop-shadow-lg
absolute top-[-1rem] right-[3rem] `}
>
<button
class={`flex items-center justify-center
rounded-l
p-1
bg-[#313338] hover:bg-[#3A3C41]`}
onClick={() => {
emit({
type: "ReplyToMessage",
event: message.event,
});
}}
>
<ReplyIcon class={`w-5 h-5 text-[#B6BAC0] hover:text-[#D9DBDE]`} />
</button>
{(myPublicKey.hex === message.author.hex || (isAdmin && isAdmin(myPublicKey.hex))) &&
([NostrKind.TEXT_NOTE, NostrKind.Long_Form].includes(message.event.kind)) &&
(
<button
class={`flex items-center justify-center
p-1
bg-[#313338] hover:bg-[#3A3C41]`}
onClick={() => {
emit({
type: "DeleteEvent",
event: message.event,
});
}}
>
<DeleteIcon class={`w-5 h-5 text-[#B6BAC0] hover:text-[#D9DBDE]`} />
</button>
)}
<button
class={`flex items-center justify-center p-1 bg-[#313338] hover:bg-[#3A3C41]`}
onClick={() => {
emit({
type: "SendReaction",
event: message.event,
content: "+",
});
}}
>
<ThumbsUpIcon class={`w-5 h-5 text-[#B6BAC0] hover:text-[#D9DBDE]`} />
</button>
<button
class={`flex items-center justify-center
p-1
bg-[#313338] hover:bg-[#3A3C41] rounded-r`}
onClick={async () => {
emit({
type: "ViewEventDetail",
message: message,
});
}}
>
<AboutIcon class={`w-5 h-5 text-[#B6BAC0] hover:text-[#D9DBDE]`} />
</button>
</div>
);
}
export type DeleteEvent = {
type: "DeleteEvent";
event: Parsed_Event;
};
function last<T>(array: Array<T>): 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<ViewUserDetail>) {
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 <ReplyTo unknown noteId={NoteID.FromString(replyEventId)} />;
}
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 (
<ReplyTo
emit={emit}
reply={{
pubkey: reply_to_event.publicKey,
content,
name: author,
picture,
}}
/>
);
}
function ReplyTo(
props: {
unknown?: false;
reply: {
pubkey: PublicKey;
content: string;
name: string;
picture: string;
};
emit: emitFunc<ViewUserDetail>;
} | {
unknown: true;
noteId: NoteID;
},
) {
return (
<div class="w-full flex flex-row">
<div class="w-10 h-5 shrink-0">
<div class="w-5 h-2.5 border-l-2 border-t-2 rounded-tl translate-y-2.5 translate-x-4 border-[#4F5058]" />
</div>
<div class="flex flex-row w-full justify-start items-center text-[#A3A6AA] gap-2 font-roboto text-sm pr-5">
{props.unknown
? (
<div class="overflow-hidden whitespace-nowrap text-overflow-ellipsis">
{props.noteId.bech32()}
</div>
)
: (
<Fragment>
<div
class={`flex items-center gap-1 cursor-pointer`}
onClick={() =>
props.emit({
type: "ViewUserDetail",
pubkey: props.reply.pubkey,
})}
>
<Avatar
class="h-4 w-4 shrink-0"
picture={props.reply.picture || ""}
/>
<button class="whitespace-nowrap md:shrink-0 truncate w-30 hover:underline">
@{props.reply.name}
</button>
</div>
<div class="overflow-hidden whitespace-nowrap truncate text-overflow-ellipsis w-[90%]">
{props.reply.content}
</div>
</Fragment>
)}
</div>
</div>
);
}
function Reactions(
props: {
myPublicKey: PublicKey;
events: Set<Parsed_Event>;
},
) {
const reactions: Map<string, {
count: number;
clicked: boolean;
}> = 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
? (
<div class={`flex flex-row justify-start items-center gap-1 py-1`}>
{Array.from(reactions).map(([reaction, { count, clicked }]) => {
return (
<div
class={`flex justify-center items-center rounded-full p-1 text-xs text-neutral-400 leading-4 cursor-default ${
clicked ? "bg-neutral-500" : "bg-neutral-700"
}`}
>
<div class={`flex justify-center items-center w-4`}>{reaction}</div>
{count > 1 && <div>{` ${count}`}</div>}
</div>
);
})}
</div>
)
: null
);
}