update channel

This commit is contained in:
Ren Amamiya
2023-05-29 22:04:50 +07:00
parent 0492729e7e
commit c8f643198e
18 changed files with 340 additions and 467 deletions

View File

@@ -4,7 +4,7 @@ import { DEFAULT_AVATAR } from "@stores/constants";
import { useProfile } from "@utils/hooks/useProfile";
export default function MiniMember({ pubkey }: { pubkey: string }) {
export function Member({ pubkey }: { pubkey: string }) {
const { user, isError, isLoading } = useProfile(pubkey);
return (

View File

@@ -1,44 +1,13 @@
import MiniMember from "@app/channel/components/miniMember";
import { channelMembersAtom } from "@stores/channel";
import { useAtomValue } from "jotai";
export default function ChannelMembers() {
const membersAsSet = useAtomValue(channelMembersAtom);
const membersAsArray = [...membersAsSet];
const miniMembersList = membersAsArray.slice(0, 4);
const totalMembers =
membersAsArray.length > 0
? `+${Intl.NumberFormat("en-US", {
notation: "compact",
maximumFractionDigits: 1,
}).format(membersAsArray.length)}`
: 0;
import { Member } from "@app/channel/components/member";
import { useChannelMessages } from "@stores/channels";
import { useEffect } from "react";
export function ChannelMembers() {
return (
<div>
<div className="group flex -space-x-2 overflow-hidden hover:-space-x-1">
{miniMembersList.map((member, index) => (
<MiniMember key={`item-${index}`} pubkey={member} />
))}
{totalMembers ? (
<div className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 ring-2 ring-zinc-950 transition-all duration-150 ease-in-out group-hover:bg-zinc-800">
<span className="text-base font-medium text-zinc-400 group-hover:text-white">
{totalMembers}
</span>
</div>
) : (
<div>
<button
type="button"
className="inline-flex h-8 items-center justify-center rounded-md bg-fuchsia-500 px-4 text-base text-white shadow-button"
>
Invite
</button>
</div>
)}
</div>
<div className="flex flex-wrap gap-1">
{[].map((member) => (
<Member key={member} pubkey={member} />
))}
</div>
);
}

View File

@@ -9,6 +9,7 @@ export function ChannelMessageList() {
const virtuosoRef = useRef(null);
const messages = useChannelMessages((state: any) => state.messages);
const sorted = messages;
const itemContent: any = useCallback(
(index: string | number) => {
@@ -33,10 +34,7 @@ export function ChannelMessageList() {
components={{
Header: () => (
<div className="relative py-4">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"
>
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-zinc-800" />
</div>
<div className="relative flex justify-center">

View File

@@ -3,13 +3,11 @@ import CancelIcon from "@icons/cancel";
import { ImagePicker } from "@shared/form/imagePicker";
import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
import { channelContentAtom, channelReplyAtom } from "@stores/channel";
import { useChannelMessages } from "@stores/channels";
import { WRITEONLY_RELAYS } from "@stores/constants";
import { dateToUnix } from "@utils/date";
import { useAtom, useAtomValue } from "jotai";
import { useResetAtom } from "jotai/utils";
import { getEventHash, getSignature } from "nostr-tools";
import { useContext } from "react";
import { useContext, useState } from "react";
export default function ChannelMessageForm({
channelID,
@@ -17,20 +15,20 @@ export default function ChannelMessageForm({
const pool: any = useContext(RelayContext);
const account = useActiveAccount((state: any) => state.account);
const [value, setValue] = useAtom(channelContentAtom);
const resetValue = useResetAtom(channelContentAtom);
const channelReply = useAtomValue(channelReplyAtom);
const resetChannelReply = useResetAtom(channelReplyAtom);
const [value, setValue] = useState("");
const [replyTo, closeReply] = useChannelMessages((state: any) => [
state.replyTo,
state.closeReply,
]);
const submitEvent = () => {
let tags: any[][];
if (channelReply.id !== null) {
if (replyTo.id !== null) {
tags = [
["e", channelID, "", "root"],
["e", channelReply.id, "", "reply"],
["p", channelReply.pubkey, ""],
["e", replyTo.id, "", "reply"],
["p", replyTo.pubkey, ""],
];
} else {
tags = [["e", channelID, "", "root"]];
@@ -50,11 +48,6 @@ export default function ChannelMessageForm({
// publish note
pool.publish(event, WRITEONLY_RELAYS);
// reset state
resetValue();
// reset channel reply
resetChannelReply();
} else {
console.log("error");
}
@@ -68,24 +61,22 @@ export default function ChannelMessageForm({
};
const stopReply = () => {
resetChannelReply();
closeReply();
};
return (
<div
className={`relative ${
channelReply.id ? "h-36" : "h-24"
replyTo.id ? "h-36" : "h-24"
} w-full overflow-hidden before:pointer-events-none before:absolute before:-inset-1 before:rounded-[11px] before:border before:border-fuchsia-500 before:opacity-0 before:ring-2 before:ring-fuchsia-500/20 before:transition after:pointer-events-none after:absolute after:inset-px after:rounded-[7px] after:shadow-highlight after:shadow-white/5 after:transition focus-within:before:opacity-100 focus-within:after:shadow-fuchsia-500/100 dark:focus-within:after:shadow-fuchsia-500/20`}
>
{channelReply.id && (
{replyTo.id && (
<div className="absolute left-0 top-0 z-10 h-14 w-full p-[2px]">
<div className="flex h-full w-full items-center justify-between rounded-t-md border-b border-zinc-700/70 bg-zinc-900 px-3">
<div className="flex w-full flex-col">
<UserReply pubkey={channelReply.pubkey} />
<UserReply pubkey={replyTo.pubkey} />
<div className="-mt-3.5 pl-[32px]">
<div className="text-base text-white">
{channelReply.content}
</div>
<div className="text-base text-white">{replyTo.content}</div>
</div>
</div>
<button
@@ -105,13 +96,12 @@ export default function ChannelMessageForm({
spellCheck={false}
placeholder="Message"
className={`relative ${
channelReply.id ? "h-36 pt-16" : "h-24 pt-3"
replyTo.id ? "h-36 pt-16" : "h-24 pt-3"
} w-full resize-none rounded-lg border border-black/5 px-3.5 pb-3 text-base shadow-input shadow-black/5 !outline-none placeholder:text-zinc-400 dark:bg-zinc-800 dark:text-white dark:shadow-black/10 dark:placeholder:text-zinc-500`}
/>
<div className="absolute bottom-2 w-full px-2">
<div className="flex w-full items-center justify-between bg-zinc-800">
<div className="flex items-center gap-2 divide-x divide-zinc-700">
<ImagePicker type="channel" />
<div className="flex items-center gap-2 pl-2" />
</div>
<div className="flex items-center gap-2">

View File

@@ -4,19 +4,18 @@ import HideIcon from "@icons/hide";
import { RelayContext } from "@shared/relayProvider";
import { Tooltip } from "@shared/tooltip";
import { useActiveAccount } from "@stores/accounts";
import { channelMessagesAtom } from "@stores/channel";
import { useChannelMessages } from "@stores/channels";
import { WRITEONLY_RELAYS } from "@stores/constants";
import { dateToUnix } from "@utils/date";
import { useAtom } from "jotai";
import { getEventHash, getSignature } from "nostr-tools";
import { Fragment, useContext, useState } from "react";
export default function MessageHideButton({ id }: { id: string }) {
export function MessageHideButton({ id }: { id: string }) {
const pool: any = useContext(RelayContext);
const account = useActiveAccount((state: any) => state.account);
const hide = useChannelMessages((state: any) => state.hideMessage);
const [isOpen, setIsOpen] = useState(false);
const [messages, setMessages] = useAtom(channelMessagesAtom);
const closeModal = () => {
setIsOpen(false);
@@ -27,34 +26,25 @@ export default function MessageHideButton({ id }: { id: string }) {
};
const hideMessage = () => {
if (account) {
const event: any = {
content: "",
created_at: dateToUnix(),
kind: 43,
pubkey: account.pubkey,
tags: [["e", id]],
};
event.id = getEventHash(event);
event.sig = getSignature(event, account.privkey);
const event: any = {
content: "",
created_at: dateToUnix(),
kind: 43,
pubkey: account.pubkey,
tags: [["e", id]],
};
// publish note
pool.publish(event, WRITEONLY_RELAYS);
event.id = getEventHash(event);
event.sig = getSignature(event, account.privkey);
// update local state
const cloneMessages = [...messages];
const targetMessage = cloneMessages.findIndex(
(message) => message.id === id,
);
// publish note
pool.publish(event, WRITEONLY_RELAYS);
cloneMessages[targetMessage]["hide"] = true;
setMessages(cloneMessages);
// update state
hide(id);
// close modal
closeModal();
} else {
console.log("error");
}
// close modal
closeModal();
};
return (

View File

@@ -1,25 +1,61 @@
import MessageHideButton from "@app/channel/components/messages/hideButton";
import MessageMuteButton from "@app/channel/components/messages/muteButton";
import MessageReplyButton from "@app/channel/components/messages/replyButton";
import { MessageHideButton } from "@app/channel/components/messages/hideButton";
import { MessageMuteButton } from "@app/channel/components/messages/muteButton";
import { MessageReplyButton } from "@app/channel/components/messages/replyButton";
import { ChannelMessageUser } from "@app/channel/components/messages/user";
import { MentionNote } from "@app/note/components/mentions/note";
import ImagePreview from "@app/note/components/preview/image";
import VideoPreview from "@app/note/components/preview/video";
import { noteParser } from "@utils/parser";
import { useMemo } from "react";
import { useMemo, useState } from "react";
export function ChannelMessageItem({ data }: { data: any }) {
const content = useMemo(() => noteParser(data), [data]);
const [hide, setHide] = useState(data.hide);
const toggleHide = () => {
setHide((prev) => !prev);
};
if (data.mute) return null;
return (
<div className="group relative flex h-min min-h-min w-full select-text flex-col px-5 py-3 hover:bg-black/20">
<div className="flex flex-col">
<ChannelMessageUser pubkey={data.pubkey} time={data.created_at} />
<div className="-mt-[20px] pl-[49px]">
<div className="whitespace-pre-line break-words text-base leading-tight">
{data.hide ? (
<span className="italic text-zinc-400">[hided message]</span>
) : (
content.parsed
)}
</div>
{hide ? (
<>
<p className="leading-tight italic text-zinc-400">
[hided message]
</p>
<button type="button" onClick={() => toggleHide()}>
show
</button>
</>
) : (
<>
<p className="whitespace-pre-line break-words text-base leading-tight">
{content.parsed}
</p>
{Array.isArray(content.images) && content.images.length ? (
<ImagePreview urls={content.images} />
) : (
<></>
)}
{Array.isArray(content.videos) && content.videos.length ? (
<VideoPreview urls={content.videos} />
) : (
<></>
)}
{Array.isArray(content.notes) && content.notes.length ? (
content.notes.map((note: string) => (
<MentionNote key={note} id={note} />
))
) : (
<></>
)}
</>
)}
</div>
</div>
<div className="absolute -top-4 right-4 z-10 hidden group-hover:inline-flex">

View File

@@ -4,18 +4,17 @@ import MuteIcon from "@icons/mute";
import { RelayContext } from "@shared/relayProvider";
import { Tooltip } from "@shared/tooltip";
import { useActiveAccount } from "@stores/accounts";
import { channelMessagesAtom } from "@stores/channel";
import { useChannelMessages } from "@stores/channels";
import { WRITEONLY_RELAYS } from "@stores/constants";
import { dateToUnix } from "@utils/date";
import { useAtom } from "jotai";
import { getEventHash, getSignature } from "nostr-tools";
import { Fragment, useContext, useState } from "react";
export default function MessageMuteButton({ pubkey }: { pubkey: string }) {
export function MessageMuteButton({ pubkey }: { pubkey: string }) {
const pool: any = useContext(RelayContext);
const account = useActiveAccount((state: any) => state.account);
const mute = useChannelMessages((state: any) => state.muteUser);
const [messages, setMessages] = useAtom(channelMessagesAtom);
const [isOpen, setIsOpen] = useState(false);
const closeModal = () => {
@@ -27,31 +26,25 @@ export default function MessageMuteButton({ pubkey }: { pubkey: string }) {
};
const muteUser = () => {
if (account) {
const event: any = {
content: "",
created_at: dateToUnix(),
kind: 44,
pubkey: account.pubkey,
tags: [["p", pubkey]],
};
event.id = getEventHash(event);
event.sig = getSignature(event, account.privkey);
const event: any = {
content: "",
created_at: dateToUnix(),
kind: 44,
pubkey: account.pubkey,
tags: [["p", pubkey]],
};
// publish note
pool.publish(event, WRITEONLY_RELAYS);
event.id = getEventHash(event);
event.sig = getSignature(event, account.privkey);
// update local state
const cloneMessages = [...messages];
const finalMessages = cloneMessages.filter(
(message) => message.pubkey !== pubkey,
);
setMessages(finalMessages);
// close modal
closeModal();
} else {
console.log("error");
}
// publish note
pool.publish(event, WRITEONLY_RELAYS);
// update state
mute(pubkey);
// close modal
closeModal();
};
return (

View File

@@ -1,20 +1,16 @@
import { Tooltip } from "@shared/tooltip";
import ReplyMessageIcon from "@icons/replyMessage";
import { Tooltip } from "@shared/tooltip";
import { useChannelMessages } from "@stores/channels";
import { channelReplyAtom } from "@stores/channel";
import { useSetAtom } from "jotai";
export default function MessageReplyButton({
export function MessageReplyButton({
id,
pubkey,
content,
}: { id: string; pubkey: string; content: string }) {
const setChannelReplyAtom = useSetAtom(channelReplyAtom);
const openReply = useChannelMessages((state: any) => state.openReply);
const createReply = () => {
setChannelReplyAtom({ id: id, pubkey: pubkey, content: content });
openReply(id, pubkey, content);
};
return (

View File

@@ -10,11 +10,14 @@ dayjs.extend(relativeTime);
export function ChannelMessageUser({
pubkey,
time,
}: { pubkey: string; time: number }) {
}: {
pubkey: string;
time: number;
}) {
const { user, isError, isLoading } = useProfile(pubkey);
return (
<div className="group flex items-start gap-3">
<div className="flex items-start gap-3">
{isError || isLoading ? (
<>
<div className="relative h-11 w-11 shrink animate-pulse rounded-md bg-zinc-800" />
@@ -35,7 +38,7 @@ export function ChannelMessageUser({
</div>
<div className="flex w-full flex-1 items-start justify-between">
<div className="flex items-baseline gap-2 text-base">
<span className="font-semibold leading-none text-white group-hover:underline">
<span className="font-semibold leading-none text-white">
{user?.nip05 || user?.name || shortenKey(pubkey)}
</span>
<span className="leading-none text-zinc-500">·</span>

View File

@@ -8,7 +8,7 @@ import { DEFAULT_AVATAR } from "@stores/constants";
import { nip19 } from "nostr-tools";
export default function ChannelMetadata({
export function ChannelMetadata({
id,
pubkey,
}: { id: string; pubkey: string }) {
@@ -23,24 +23,24 @@ export default function ChannelMetadata({
};
return (
<div className="inline-flex items-center gap-2">
<div className="relative shrink-0 rounded-md">
<div className="flex flex-col gap-2">
<div className="relative shrink-0 rounded-md h-11 w-11">
<Image
src={metadata?.picture || DEFAULT_AVATAR}
alt={id}
className="h-8 w-8 rounded bg-zinc-900 object-contain ring-2 ring-zinc-950"
className="h-11 w-11 rounded-md object-contain bg-zinc-900"
/>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<h5 className="truncate text-base font-medium leading-none text-white">
<div className="flex flex-col gap-2">
<div className="inline-flex items-center gap-1">
<h5 className="leading-none text-lg font-semibold">
{metadata?.name}
</h5>
<button type="button" onClick={() => copyNoteID()}>
<CopyIcon width={14} height={14} className="text-zinc-400" />
</button>
</div>
<p className="text-base leading-none text-zinc-400">
<p className="leading-tight text-zinc-400">
{metadata?.about || (noteID && `${noteID.substring(0, 24)}...`)}
</p>
</div>

View File

@@ -1,8 +1,8 @@
import ChannelBlackList from "@app/channel/components/blacklist";
import ChannelMembers from "@app/channel/components/members";
import { ChannelMembers } from "@app/channel/components/members";
import { ChannelMessageList } from "@app/channel/components/messageList";
import ChannelMessageForm from "@app/channel/components/messages/form";
import ChannelMetadata from "@app/channel/components/metadata";
import { ChannelMetadata } from "@app/channel/components/metadata";
import ChannelUpdateModal from "@app/channel/components/updateModal";
import { RelayContext } from "@shared/relayProvider";
import { useActiveAccount } from "@stores/accounts";
@@ -66,14 +66,23 @@ export function Page() {
READONLY_RELAYS,
(event: { id: string; pubkey: string }) => {
const message: any = event;
// handle hide message
if (hided.includes(event.id)) {
message["hide"] = true;
} else {
message["hide"] = false;
}
if (!muted.array.includes(event.pubkey)) {
addMessage(message);
// handle mute user
if (muted.array.includes(event.pubkey)) {
message["mute"] = true;
} else {
message["mute"] = false;
}
// add to store
addMessage(message);
},
);
@@ -101,23 +110,17 @@ export function Page() {
</div>
</div>
</div>
<div className="col-span-1">
<div className="col-span-1 flex flex-col">
<div
data-tauri-drag-region
className="h-11 w-full shrink-0 inline-flex items-center justify-center border-b border-zinc-900"
/>
<div>
<div className="p-3 flex flex-col gap-3">
<ChannelMetadata id={channelID} pubkey={channelPubkey} />
</div>
<div className="flex items-center gap-2">
<ChannelMembers />
{!muted ? <></> : <ChannelBlackList blacklist={muted.original} />}
{account ? (
account.pubkey === channelPubkey && (
<ChannelUpdateModal id={channelID} />
)
) : (
<></>
{muted && <ChannelBlackList blacklist={muted.original} />}
{account && account.pubkey === channelPubkey && (
<ChannelUpdateModal id={channelID} />
)}
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { ChatMessageUser } from "@app/chat/components/messages/user";
import { useDecryptMessage } from "@app/chat/hooks/useDecryptMessage";
import { MentionNote } from "@app/note/components/mentions/note";
import ImagePreview from "@app/note/components/preview/image";
import VideoPreview from "@app/note/components/preview/video";
import { noteParser } from "@utils/parser";
@@ -40,6 +41,13 @@ export const ChatMessageItem = memo(function ChatMessageItem({
) : (
<></>
)}
{Array.isArray(content.notes) && content.notes.length ? (
content.notes.map((note: string) => (
<MentionNote key={note} id={note} />
))
) : (
<></>
)}
</div>
</div>
</div>

View File

@@ -1,102 +0,0 @@
import PlusIcon from "@icons/plus";
import { channelContentAtom } from "@stores/channel";
import { createBlobFromFile } from "@utils/createBlobFromFile";
import { open } from "@tauri-apps/api/dialog";
import { Body, fetch } from "@tauri-apps/api/http";
import { useSetAtom } from "jotai";
import { useState } from "react";
export function ImagePicker({ type }: { type: string }) {
let atom;
switch (type) {
case "channel":
atom = channelContentAtom;
break;
default:
throw new Error("Invalid type");
}
const [loading, setLoading] = useState(false);
const setValue = useSetAtom(atom);
const openFileDialog = async () => {
const selected: any = await open({
multiple: false,
filters: [
{
name: "Image",
extensions: ["png", "jpeg", "jpg", "gif"],
},
],
});
if (Array.isArray(selected)) {
// user selected multiple files
} else if (selected === null) {
// user cancelled the selection
} else {
setLoading(true);
const filename = selected.split("/").pop();
const file = await createBlobFromFile(selected);
const buf = await file.arrayBuffer();
const res: { data: { file: { id: string } } } = await fetch(
"https://void.cat/upload?cli=false",
{
method: "POST",
timeout: 5,
headers: {
accept: "*/*",
"Content-Type": "application/octet-stream",
"V-Filename": filename,
"V-Description": "Upload from https://lume.nu",
"V-Strip-Metadata": "true",
},
body: Body.bytes(buf),
},
);
const webpImage = `https://void.cat/d/${res.data.file.id}.webp`;
setValue((content: string) => `${content} ${webpImage}`);
setLoading(false);
}
};
return (
<button
type="button"
onClick={() => openFileDialog()}
className="inline-flex h-6 w-6 items-center justify-center rounded-md hover:bg-zinc-700"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<title id="loading">Loading</title>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<PlusIcon width={16} height={16} className="text-zinc-400" />
)}
</button>
);
}

View File

@@ -1,31 +0,0 @@
import { atom } from "jotai";
import { atomWithReset } from "jotai/utils";
// channel reply id
export const channelReplyAtom = atomWithReset({
id: null,
pubkey: null,
content: null,
});
// channel messages
export const channelMessagesAtom = atomWithReset([]);
export const sortedChannelMessagesAtom = atom((get) => {
const messages = get(channelMessagesAtom);
return messages.sort(
(x: { created_at: number }, y: { created_at: number }) =>
x.created_at - y.created_at,
);
});
// channel user list
export const channelMembersAtom = atom((get) => {
const messages = get(channelMessagesAtom);
const uniqueMembers = new Set(
messages.map((m: { pubkey: string }) => m.pubkey),
);
return uniqueMembers;
});
// channel message content
export const channelContentAtom = atomWithReset("");

View File

@@ -1,5 +1,6 @@
import { getChannels } from "@utils/storage";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
export const useChannels = create((set) => ({
channels: [],
@@ -9,10 +10,31 @@ export const useChannels = create((set) => ({
},
}));
export const useChannelMessages = create((set) => ({
messages: [],
replyTo: null,
add: (message: any) => {
set((state: any) => ({ messages: [...state.messages, message] }));
},
}));
export const useChannelMessages = create(
immer((set) => ({
messages: [],
members: new Set(),
replyTo: { id: null, pubkey: null, content: null },
add: (message: any) => {
set((state: any) => ({ messages: [...state.messages, message] }));
},
openReply: (id: string, pubkey: string, content: string) => {
set(() => ({ replyTo: { id, pubkey, content } }));
},
closeReply: () => {
set(() => ({ replyTo: { id: null, pubkey: null, content: null } }));
},
hideMessage: (id: string) => {
set((state) => {
const target = state.messages.findIndex((m) => m.id === id);
state.messages[target]["hide"] = true;
});
},
muteUser: (pubkey: string) => {
set((state) => {
const target = state.messages.findIndex((m) => m.pubkey === pubkey);
state.messages[target]["mute"] = true;
});
},
})),
);

View File

@@ -286,12 +286,17 @@ export async function getChatMessages(
sender_pubkey: string,
) {
const db = await connect();
let receiver = [];
const sender: any = await db.select(
`SELECT * FROM chats WHERE sender_pubkey = "${sender_pubkey}" AND receiver_pubkey = "${receiver_pubkey}";`,
);
const receiver: any = await db.select(
`SELECT * FROM chats WHERE sender_pubkey = "${receiver_pubkey}" AND receiver_pubkey = "${sender_pubkey}";`,
);
if (receiver_pubkey !== sender_pubkey) {
receiver = await db.select(
`SELECT * FROM chats WHERE sender_pubkey = "${receiver_pubkey}" AND receiver_pubkey = "${sender_pubkey}";`,
);
}
const result = [...sender, ...receiver].sort(
(x: { created_at: number }, y: { created_at: number }) =>