mirror of
https://github.com/irislib/iris-messenger.git
synced 2024-09-19 17:46:33 +00:00
chat refactoring, wip group chat
This commit is contained in:
parent
0e1ea962c4
commit
4beee82679
@ -134,7 +134,6 @@ class Main extends Component<Props, ReactState> {
|
||||
<Notifications path="/notifications" />
|
||||
<Chat path="/chat/hashtag/:hashtag?" />
|
||||
<Chat path="/chat/:id?" />
|
||||
<Chat path="/chat/new/:id" />
|
||||
<Note path="/post/:id+" />
|
||||
<Torrent path="/torrent/:id+" />
|
||||
<About path="/about" />
|
||||
|
@ -10,7 +10,7 @@ import Name from './user/Name';
|
||||
import Torrent from './Torrent';
|
||||
|
||||
const PrivateMessage = (props) => {
|
||||
const [text, setText] = useState('');
|
||||
const [text, setText] = useState(props.text || '');
|
||||
|
||||
useEffect(() => {
|
||||
$('a').click((e) => {
|
||||
@ -20,9 +20,11 @@ const PrivateMessage = (props) => {
|
||||
route(href.replace('https://iris.to/', ''));
|
||||
}
|
||||
});
|
||||
Key.decryptMessage(props.id, (decryptedText) => {
|
||||
setText(decryptedText);
|
||||
});
|
||||
if (!text) {
|
||||
Key.decryptMessage(props.id, (decryptedText) => {
|
||||
setText(decryptedText);
|
||||
});
|
||||
}
|
||||
}, [props.id]);
|
||||
|
||||
const onNameClick = () => {
|
||||
|
@ -688,7 +688,7 @@ const Events = {
|
||||
PubSub.publish(event as Event);
|
||||
|
||||
console.log('publishing event', event);
|
||||
this.handle(event as Event);
|
||||
this.handle(event as Event, true);
|
||||
|
||||
// also publish at most 10 events referred to in tags
|
||||
const referredEvents = event.tags
|
||||
|
@ -1,12 +1,11 @@
|
||||
import { PaperAirplaneIcon } from '@heroicons/react/24/solid';
|
||||
|
||||
import Show from '../../components/helpers/Show';
|
||||
import Key from '../../nostr/Key';
|
||||
import { translate as t } from '../../translations/Translation.mjs';
|
||||
import View from '../View';
|
||||
|
||||
import ChatList from './ChatList';
|
||||
import PrivateChat from './PrivateChat';
|
||||
import ChatMessages from './ChatMessages';
|
||||
import NewChat from './NewChat';
|
||||
|
||||
class Chat extends View {
|
||||
id: string;
|
||||
@ -17,22 +16,30 @@ class Chat extends View {
|
||||
this.hideSideBar = true;
|
||||
}
|
||||
|
||||
renderView() {
|
||||
const hexId = Key.toNostrHexAddress(this.props.id) || undefined;
|
||||
return (
|
||||
<div className="flex flex-row">
|
||||
<ChatList activeChat={hexId} className={hexId ? 'hidden md:flex' : 'flex'} />
|
||||
<Show when={hexId}>
|
||||
<PrivateChat id={hexId || ''} key={hexId} />
|
||||
</Show>
|
||||
<Show when={!hexId}>
|
||||
<div className="hidden md:flex flex-col items-center justify-center flex-1">
|
||||
<div className="my-4">
|
||||
<PaperAirplaneIcon className="w-24 h-24 text-neutral-400" />
|
||||
</div>
|
||||
<div className="text-neutral-400">{t('dm_privacy_warning')}</div>
|
||||
renderContent = (id) => {
|
||||
if (id === 'new') {
|
||||
return <NewChat />;
|
||||
} else if (id) {
|
||||
return <ChatMessages id={id} key={id} />;
|
||||
} else {
|
||||
return (
|
||||
<div className="hidden md:flex flex-col items-center justify-center flex-1">
|
||||
<div className="my-4">
|
||||
<PaperAirplaneIcon className="w-24 h-24 text-neutral-400" />
|
||||
</div>
|
||||
</Show>
|
||||
<div className="text-neutral-400">{t('dm_privacy_warning')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderView() {
|
||||
const { id } = this.props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row h-full">
|
||||
<ChatList activeChat={id} className={id ? 'hidden md:flex' : 'flex'} />
|
||||
{this.renderContent(id)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,8 +1,11 @@
|
||||
import { PlusIcon } from '@heroicons/react/24/solid';
|
||||
import $ from 'jquery';
|
||||
import { route } from 'preact-router';
|
||||
|
||||
import BaseComponent from '../../BaseComponent';
|
||||
import localState from '../../LocalState';
|
||||
import Events from '../../nostr/Events';
|
||||
import Key from '../../nostr/Key';
|
||||
import { translate as t } from '../../translations/Translation.mjs';
|
||||
|
||||
import ChatListItem from './ChatListItem';
|
||||
@ -17,6 +20,26 @@ interface ChatListState {
|
||||
sortedChats: Array<string>;
|
||||
}
|
||||
|
||||
const NewChatButton = ({ active }) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`flex p-2 flex-row gap-4 h-16 items-center cursor-pointer hover:bg-neutral-900 ${
|
||||
active ? 'bg-neutral-700' : ''
|
||||
}`}
|
||||
onClick={() => route(`/chat/new`)}
|
||||
>
|
||||
<div className="flex justify-center items-center w-12 h-12 rounded-full">
|
||||
<PlusIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
<div className="flex flex-col">
|
||||
<span className="name">{t('new_chat')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
class ChatList extends BaseComponent<ChatListProps, ChatListState> {
|
||||
constructor(props: ChatListProps) {
|
||||
super(props);
|
||||
@ -68,7 +91,9 @@ class ChatList extends BaseComponent<ChatListProps, ChatListState> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const activeChat = this.props.activeChat;
|
||||
const activeChat =
|
||||
(this.props.activeChat && Key.toNostrHexAddress(this.props.activeChat)) ||
|
||||
this.props.activeChat;
|
||||
|
||||
return (
|
||||
<section
|
||||
@ -87,6 +112,7 @@ class ChatList extends BaseComponent<ChatListProps, ChatListState> {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<NewChatButton active={activeChat === 'new'} />
|
||||
{this.state.sortedChats.map((pubkey) => (
|
||||
<ChatListItem
|
||||
active={pubkey === activeChat}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { PaperAirplaneIcon } from '@heroicons/react/24/outline';
|
||||
import $ from 'jquery';
|
||||
import { getEventHash, getSignature, nip04 } from 'nostr-tools';
|
||||
|
||||
import BaseComponent from '../../BaseComponent';
|
||||
import Helpers from '../../Helpers';
|
||||
import localState from '../../LocalState';
|
||||
import Events from '../../nostr/Events';
|
||||
@ -12,24 +12,28 @@ interface ChatMessageFormProps {
|
||||
class?: string;
|
||||
autofocus?: boolean;
|
||||
onSubmit?: () => void;
|
||||
keyPair?: { pubKey: string; privKey: string };
|
||||
}
|
||||
|
||||
class ChatMessageForm extends BaseComponent<ChatMessageFormProps> {
|
||||
componentDidMount() {
|
||||
if (!Helpers.isMobile && this.props.autofocus !== false) {
|
||||
$(this.base).find('.new-msg').focus();
|
||||
}
|
||||
}
|
||||
const ChatMessageForm: React.FC<ChatMessageFormProps> = ({
|
||||
activeChat,
|
||||
class: classProp,
|
||||
autofocus,
|
||||
onSubmit,
|
||||
keyPair,
|
||||
}) => {
|
||||
const [message, setMessage] = useState<string>('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
componentDidUpdate() {
|
||||
if (!Helpers.isMobile && this.props.autofocus !== false) {
|
||||
$(this.base).find('.new-msg').focus();
|
||||
useEffect(() => {
|
||||
if (!Helpers.isMobile && autofocus !== false) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
}, [autofocus]);
|
||||
|
||||
encrypt(text: string) {
|
||||
const privateEncrypt = (text: string) => {
|
||||
try {
|
||||
const theirPub = Key.toNostrHexAddress(this.props.activeChat);
|
||||
const theirPub = Key.toNostrHexAddress(activeChat);
|
||||
if (!theirPub) {
|
||||
throw new Error('invalid public key ' + theirPub);
|
||||
}
|
||||
@ -37,73 +41,77 @@ class ChatMessageForm extends BaseComponent<ChatMessageFormProps> {
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async onSubmit(e: Event) {
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const textEl = $(this.base).find('.new-msg');
|
||||
const text = textEl.val() as string;
|
||||
if (!text.length) {
|
||||
if (!message.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await this.encrypt(text);
|
||||
|
||||
const recipient = Key.toNostrHexAddress(this.props.activeChat);
|
||||
if (!recipient) {
|
||||
throw new Error('invalid public key ' + recipient);
|
||||
const event = { kind: 4, created_at: Math.floor(Date.now() / 1000) } as any;
|
||||
if (keyPair) {
|
||||
// group message
|
||||
event.content = await nip04.encrypt(keyPair.privKey, keyPair.pubKey, message);
|
||||
event.pubkey = keyPair.pubKey;
|
||||
event.tags = [['p', keyPair.pubKey]];
|
||||
console.log('event', event);
|
||||
event.id = getEventHash(event);
|
||||
event.sig = getSignature(event, keyPair.privKey);
|
||||
} else {
|
||||
const recipient = Key.toNostrHexAddress(activeChat);
|
||||
event.content = await privateEncrypt(message);
|
||||
event.tags = [['p', recipient]];
|
||||
if (!recipient) {
|
||||
throw new Error('invalid public key ' + recipient);
|
||||
}
|
||||
}
|
||||
Events.publish({
|
||||
kind: 4,
|
||||
content,
|
||||
tags: [['p', recipient]],
|
||||
});
|
||||
textEl.val('');
|
||||
|
||||
this.props.onSubmit?.();
|
||||
}
|
||||
Events.publish(event);
|
||||
|
||||
onMsgTextInput(event: Event) {
|
||||
localState
|
||||
.get('channels')
|
||||
.get(this.props.activeChat)
|
||||
.get('msgDraft')
|
||||
.put($(event.target).val() as string);
|
||||
}
|
||||
setMessage('');
|
||||
|
||||
onKeyDown(e: KeyboardEvent) {
|
||||
onSubmit?.();
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const value = e.target.value;
|
||||
setMessage(value);
|
||||
localState.get('channels').get(activeChat).get('msgDraft').put(value);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
this.onSubmit(e as Event);
|
||||
handleSubmit(e as any);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<form
|
||||
autocomplete="off"
|
||||
class={`flex flex-1 flex-row gap-2 p-2 message-form sticky w-full bottom-0 w-96 max-w-screen bg-black ${
|
||||
this.props.class || ''
|
||||
}`}
|
||||
onSubmit={(e: Event) => this.onSubmit(e)}
|
||||
>
|
||||
<input
|
||||
className="input input-sm flex-1 new-msg"
|
||||
onInput={(e: Event) => this.onMsgTextInput(e)}
|
||||
onKeyDown={(e: KeyboardEvent) => this.onKeyDown(e)}
|
||||
type="text"
|
||||
placeholder="Type a message"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="sentences"
|
||||
spellCheck={true}
|
||||
/>
|
||||
<button className="btn btn-neutral btn-sm" style={{ marginRight: '0' }}>
|
||||
<PaperAirplaneIcon onClick={(e: MouseEvent) => this.onSubmit(e as Event)} width="24" />
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<form
|
||||
autoComplete="off"
|
||||
className={`flex flex-1 flex-row gap-2 p-2 message-form sticky w-full bottom-0 w-96 max-w-screen bg-black ${
|
||||
classProp || ''
|
||||
}`}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="input input-sm flex-1 new-msg"
|
||||
onInput={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
type="text"
|
||||
placeholder="Type a message"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="sentences"
|
||||
spellCheck={true}
|
||||
value={message}
|
||||
/>
|
||||
<button className="btn btn-neutral btn-sm" style={{ marginRight: '0' }}>
|
||||
<PaperAirplaneIcon width="24" />
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessageForm;
|
||||
|
243
src/js/views/chat/ChatMessages.tsx
Normal file
243
src/js/views/chat/ChatMessages.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import $ from 'jquery';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { getPublicKey, nip04 } from 'nostr-tools';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
import Show from '../../components/helpers/Show';
|
||||
import PrivateMessage from '../../components/PrivateMessage';
|
||||
import Helpers from '../../Helpers';
|
||||
import localState from '../../LocalState';
|
||||
import Events from '../../nostr/Events';
|
||||
import Key from '../../nostr/Key';
|
||||
import PubSub from '../../nostr/PubSub';
|
||||
import { translate as t } from '../../translations/Translation.mjs';
|
||||
|
||||
import ChatMessageForm from './ChatMessageForm.tsx';
|
||||
|
||||
function ChatMessages({ id }) {
|
||||
const ref = useRef(null);
|
||||
const [sortedMessages, setSortedMessages] = useState([] as any[]);
|
||||
const [stickToBottom, setStickToBottom] = useState(true);
|
||||
const [keyPair, setKeyPair] = useState(
|
||||
undefined as { pubKey: string; privKey: string } | undefined,
|
||||
);
|
||||
let unsub;
|
||||
let messageViewScrollHandler;
|
||||
|
||||
const addFloatingDaySeparator = () => {
|
||||
let currentDaySeparator = $('.day-separator').last();
|
||||
let pos = currentDaySeparator.position();
|
||||
while (currentDaySeparator && pos && pos.top - 55 > 0) {
|
||||
currentDaySeparator = currentDaySeparator.prevAll('.day-separator').first();
|
||||
pos = currentDaySeparator.position();
|
||||
}
|
||||
const s = currentDaySeparator.clone();
|
||||
const center = $('<div>')
|
||||
.css({ position: 'fixed', top: 70, 'text-align': 'center' })
|
||||
.attr('id', 'floating-day-separator')
|
||||
.width($('#message-view').width())
|
||||
.append(s);
|
||||
$('#floating-day-separator').remove();
|
||||
setTimeout(() => s.fadeOut(), 2000);
|
||||
$('#message-view').prepend(center);
|
||||
};
|
||||
|
||||
const toggleScrollDownBtn = () => {
|
||||
const el = $('#message-view');
|
||||
const scrolledToBottom = el[0].scrollHeight - el.scrollTop() <= el.outerHeight() + 200;
|
||||
if (scrolledToBottom) {
|
||||
$('#scroll-down-btn:visible').fadeOut(150);
|
||||
} else {
|
||||
$('#scroll-down-btn:not(:visible)').fadeIn(150);
|
||||
}
|
||||
};
|
||||
|
||||
const onMessageViewScroll = () => {
|
||||
messageViewScrollHandler =
|
||||
messageViewScrollHandler ||
|
||||
throttle(() => {
|
||||
if ($('#attachment-preview:visible').length) {
|
||||
return;
|
||||
}
|
||||
addFloatingDaySeparator();
|
||||
toggleScrollDownBtn();
|
||||
}, 200);
|
||||
messageViewScrollHandler();
|
||||
};
|
||||
|
||||
const scrollDown = () => {
|
||||
Helpers.scrollToMessageListBottom();
|
||||
const el = document.getElementById('message-list');
|
||||
el && (el.style.paddingBottom = '0');
|
||||
};
|
||||
|
||||
const renderMainView = () => {
|
||||
let mainView;
|
||||
const myPub = Key.getPubKey();
|
||||
const now = new Date();
|
||||
const nowStr = now.toLocaleDateString();
|
||||
let previousDateStr;
|
||||
let previousFrom;
|
||||
const msgListContent = [] as any[];
|
||||
|
||||
if (id && id.length > 4) {
|
||||
sortedMessages.forEach((msgOrId) => {
|
||||
let msg;
|
||||
if (typeof msgOrId === 'string') {
|
||||
msg = Events.db.by('id', msgOrId);
|
||||
} else {
|
||||
msg = msgOrId;
|
||||
}
|
||||
if (!msg) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(msg.created_at * 1000);
|
||||
let isDifferentDay;
|
||||
if (date) {
|
||||
const dateStr = date.toLocaleDateString();
|
||||
if (dateStr !== previousDateStr) {
|
||||
isDifferentDay = true;
|
||||
const separatorText = Helpers.getDaySeparatorText(date, dateStr, now, nowStr);
|
||||
msgListContent.push(
|
||||
<div className="px-2 py-1 inline-block day-separator bg-black opacity-50 text-white rounded-full">
|
||||
{t(separatorText.toLowerCase())}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
previousDateStr = dateStr;
|
||||
}
|
||||
|
||||
let showName = false;
|
||||
if (
|
||||
msg.pubkey !== myPub &&
|
||||
(isDifferentDay || (previousFrom && msg.pubkey !== previousFrom))
|
||||
) {
|
||||
msgListContent.push(<div className="from-separator" />);
|
||||
showName = true;
|
||||
}
|
||||
previousFrom = msg.pubkey;
|
||||
msgListContent.push(
|
||||
<PrivateMessage
|
||||
{...msg}
|
||||
showName={showName}
|
||||
selfAuthored={msg.pubkey === myPub}
|
||||
key={`${msg.created_at}${msg.pubkey}`}
|
||||
chatId={id}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
mainView = (
|
||||
<div
|
||||
className="main-view p-2 h-screen overflow-y-scroll overflow-x-hidden"
|
||||
id="message-view"
|
||||
onScroll={() => onMessageViewScroll()}
|
||||
>
|
||||
<div id="message-list">
|
||||
{msgListContent}
|
||||
<div className="italic my-2 text-neutral-500 w-full text-center">
|
||||
{t('dm_privacy_warning')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="attachment-preview"
|
||||
className="attachment-preview"
|
||||
style={{ display: 'none' }}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return mainView;
|
||||
};
|
||||
|
||||
const messagesById = new Map<string, any>();
|
||||
|
||||
const renderMsgForm = () => {
|
||||
return (
|
||||
<>
|
||||
<div id="scroll-down-btn" style={{ display: 'none' }} onClick={() => scrollDown()}>
|
||||
<ChevronDownIcon width="24" />
|
||||
</div>
|
||||
<ChatMessageForm key={id} activeChat={id} onSubmit={() => scrollDown()} keyPair={keyPair} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const hexId = Key.toNostrHexAddress(id);
|
||||
const subscribePrivate = (hexId) => {
|
||||
unsub = PubSub.subscribe({ kinds: [4], '#p': [Key.getPubKey()], authors: [hexId] });
|
||||
Events.getDirectMessagesByUser(hexId, (msgIds) => {
|
||||
if (msgIds) {
|
||||
setSortedMessages(msgIds.reverse());
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const subscribePublic = () => {
|
||||
localState.get(`groups/${id}/key`).on((privKey) => {
|
||||
console.log('group private key', privKey);
|
||||
const pubKey = getPublicKey(privKey);
|
||||
console.log('group public key', pubKey);
|
||||
setKeyPair({ privKey, pubKey });
|
||||
unsub = PubSub.subscribe(
|
||||
{ kinds: [4], '#p': [pubKey], authors: [pubKey] },
|
||||
async (event) => {
|
||||
console.log('got group message', event);
|
||||
const decrypted = await nip04.decrypt(privKey, pubKey, event.content);
|
||||
messagesById.set(event.id, { ...event, text: decrypted });
|
||||
setSortedMessages(Array.from(messagesById.values()));
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
hexId ? subscribePrivate(hexId) : subscribePublic();
|
||||
|
||||
const container = document.getElementById('message-list');
|
||||
if (container) {
|
||||
container.style.paddingBottom = '0';
|
||||
container.style.paddingTop = '0';
|
||||
const el = $('#message-view');
|
||||
el.off('scroll').on('scroll', () => {
|
||||
const scrolledToBottom = el[0].scrollHeight - el.scrollTop() == el.outerHeight();
|
||||
if (stickToBottom && !scrolledToBottom) {
|
||||
setStickToBottom(false);
|
||||
} else if (!stickToBottom && scrolledToBottom) {
|
||||
setStickToBottom(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsub && unsub();
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stickToBottom) {
|
||||
Helpers.scrollToMessageListBottom();
|
||||
}
|
||||
|
||||
$('.msg-content img')
|
||||
.off('load')
|
||||
.on('load', () => stickToBottom && Helpers.scrollToMessageListBottom());
|
||||
}, [stickToBottom]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{'Messages'}</title>
|
||||
</Helmet>
|
||||
<div id="chat-main" ref={ref} className={`${id ? '' : 'hidden'} flex-1 pb-12`}>
|
||||
{renderMainView()}
|
||||
<Show when={id && id.length > 4}>{renderMsgForm()}</Show>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatMessages;
|
0
src/js/views/chat/GroupChat.tsx
Normal file
0
src/js/views/chat/GroupChat.tsx
Normal file
34
src/js/views/chat/NewChat.tsx
Normal file
34
src/js/views/chat/NewChat.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { generatePrivateKey } from 'nostr-tools';
|
||||
import { route } from 'preact-router';
|
||||
|
||||
import localState from '../../LocalState';
|
||||
import { translate as t } from '../../translations/Translation.mjs';
|
||||
|
||||
export default function NewChat() {
|
||||
const startNewGroup = () => {
|
||||
const randomChatID = Math.floor(Math.random() * 1000000000);
|
||||
const newNostrKey = generatePrivateKey();
|
||||
localState.get(`groups/${randomChatID}/key`).put(newNostrKey);
|
||||
route(`/chat/${randomChatID}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center h-full">
|
||||
<button className="btn btn-primary" onClick={startNewGroup}>
|
||||
{t('start_new_group')}
|
||||
</button>
|
||||
<div className="my-4">{t('or')}</div>
|
||||
<div className="my-4 flex gap-2 justify-center items-center">
|
||||
<input
|
||||
placeholder="Paste a chat link"
|
||||
type="text"
|
||||
id="pasteLink"
|
||||
className="text-center input border border-gray-400 rounded p-2"
|
||||
/>
|
||||
<button id="scanQR" className="btn btn-neutral">
|
||||
{t('Scan QR Code')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,285 +0,0 @@
|
||||
import { createRef } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import $ from 'jquery';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { PureComponent } from 'preact/compat';
|
||||
|
||||
import PrivateMessage from '../../components/PrivateMessage';
|
||||
import Helpers from '../../Helpers';
|
||||
import Events from '../../nostr/Events';
|
||||
import Key from '../../nostr/Key';
|
||||
import PubSub from '../../nostr/PubSub';
|
||||
import Session from '../../nostr/Session';
|
||||
import { translate as t } from '../../translations/Translation.mjs';
|
||||
|
||||
import ChatMessageForm from './ChatMessageForm.tsx';
|
||||
|
||||
interface PrivateChatState {
|
||||
decryptedMessages: Record<string, any>;
|
||||
sortedMessages: any[];
|
||||
sortedParticipants: any[];
|
||||
showParticipants: boolean;
|
||||
stickToBottom: boolean;
|
||||
noLongerParticipant: boolean;
|
||||
}
|
||||
|
||||
interface PrivateChatProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
class PrivateChat extends PureComponent<PrivateChatProps, PrivateChatState> {
|
||||
unsub: any;
|
||||
chat: any;
|
||||
ref: any;
|
||||
messageViewScrollHandler: any;
|
||||
|
||||
constructor(props: PrivateChatProps) {
|
||||
super(props);
|
||||
this.ref = createRef();
|
||||
this.state = {
|
||||
decryptedMessages: {},
|
||||
sortedMessages: [],
|
||||
sortedParticipants: [],
|
||||
showParticipants: true,
|
||||
stickToBottom: true,
|
||||
noLongerParticipant: false,
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate() {
|
||||
return true;
|
||||
}
|
||||
|
||||
updateLastOpened() {
|
||||
const hexId = Key.toNostrHexAddress(this.props.id);
|
||||
Session.public?.set('chats/' + hexId + '/lastOpened', Math.floor(Date.now() / 1000));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const hexId = Key.toNostrHexAddress(this.props.id);
|
||||
if (!hexId) {
|
||||
console.error('no id');
|
||||
return;
|
||||
}
|
||||
this.unsub = PubSub.subscribe({ kinds: [4], '#p': [Key.getPubKey()], authors: [hexId] });
|
||||
Events.getDirectMessagesByUser(hexId, (msgIds) => {
|
||||
if (msgIds) {
|
||||
this.setState({ sortedMessages: msgIds.reverse() });
|
||||
}
|
||||
});
|
||||
this.updateLastOpened();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) {
|
||||
this.updateLastOpened();
|
||||
}
|
||||
});
|
||||
|
||||
const container = document.getElementById('message-list');
|
||||
if (container) {
|
||||
container.style.paddingBottom = '0';
|
||||
container.style.paddingTop = '0';
|
||||
const el = $('#message-view');
|
||||
el.off('scroll').on('scroll', () => {
|
||||
const scrolledToBottom = el[0].scrollHeight - el.scrollTop() == el.outerHeight();
|
||||
if (this.state.stickToBottom && !scrolledToBottom) {
|
||||
this.setState({ stickToBottom: false });
|
||||
} else if (!this.state.stickToBottom && scrolledToBottom) {
|
||||
this.setState({ stickToBottom: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) {
|
||||
this.updateLastOpened();
|
||||
}
|
||||
});
|
||||
this.unsub && this.unsub();
|
||||
}
|
||||
|
||||
componentDidUpdate(previousProps: any) {
|
||||
if (this.state.stickToBottom) {
|
||||
Helpers.scrollToMessageListBottom();
|
||||
}
|
||||
|
||||
if (previousProps.id !== this.props.id) {
|
||||
this.updateLastOpened();
|
||||
}
|
||||
|
||||
$('.msg-content img')
|
||||
.off('load')
|
||||
.on('load', () => this.state.stickToBottom && Helpers.scrollToMessageListBottom());
|
||||
|
||||
setTimeout(() => {
|
||||
if (
|
||||
this.chat &&
|
||||
!this.chat.uuid &&
|
||||
Key.toNostrHexAddress(this.props.id) !== Key.getPubKey()
|
||||
) {
|
||||
if ($('.msg.our').length && !$('.msg.their').length && !this.chat.theirMsgsLastSeenTime) {
|
||||
$('#not-seen-by-them').slideDown();
|
||||
} else {
|
||||
$('#not-seen-by-them').slideUp();
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Original Preact methods converted to TypeScript with React JSX
|
||||
addFloatingDaySeparator() {
|
||||
let currentDaySeparator = $('.day-separator').last();
|
||||
let pos = currentDaySeparator.position();
|
||||
while (currentDaySeparator && pos && pos.top - 55 > 0) {
|
||||
currentDaySeparator = currentDaySeparator.prevAll('.day-separator').first();
|
||||
pos = currentDaySeparator.position();
|
||||
}
|
||||
const s = currentDaySeparator.clone();
|
||||
const center = $('<div>')
|
||||
.css({ position: 'fixed', top: 70, 'text-align': 'center' })
|
||||
.attr('id', 'floating-day-separator')
|
||||
.width($('#message-view').width())
|
||||
.append(s);
|
||||
$('#floating-day-separator').remove();
|
||||
setTimeout(() => s.fadeOut(), 2000);
|
||||
$('#message-view').prepend(center);
|
||||
}
|
||||
|
||||
toggleScrollDownBtn() {
|
||||
const el = $('#message-view');
|
||||
const scrolledToBottom = el[0].scrollHeight - el.scrollTop() <= el.outerHeight() + 200;
|
||||
if (scrolledToBottom) {
|
||||
$('#scroll-down-btn:visible').fadeOut(150);
|
||||
} else {
|
||||
$('#scroll-down-btn:not(:visible)').fadeIn(150);
|
||||
}
|
||||
}
|
||||
|
||||
onMessageViewScroll() {
|
||||
this.messageViewScrollHandler =
|
||||
this.messageViewScrollHandler ||
|
||||
throttle(() => {
|
||||
if ($('#attachment-preview:visible').length) {
|
||||
return;
|
||||
}
|
||||
this.addFloatingDaySeparator();
|
||||
this.toggleScrollDownBtn();
|
||||
}, 200);
|
||||
this.messageViewScrollHandler();
|
||||
}
|
||||
|
||||
scrollDown() {
|
||||
Helpers.scrollToMessageListBottom();
|
||||
const el = document.getElementById('message-list');
|
||||
el && (el.style.paddingBottom = '0');
|
||||
}
|
||||
|
||||
renderMainView() {
|
||||
let mainView;
|
||||
if (this.props.id && this.props.id.length > 20) {
|
||||
const myPub = Key.getPubKey();
|
||||
const now = new Date();
|
||||
const nowStr = now.toLocaleDateString();
|
||||
let previousDateStr;
|
||||
let previousFrom;
|
||||
const msgListContent: any[] = [];
|
||||
this.state.sortedMessages.forEach((msgId) => {
|
||||
const msg = Events.db.by('id', msgId);
|
||||
if (!msg) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(msg.created_at * 1000);
|
||||
let isDifferentDay;
|
||||
if (date) {
|
||||
const dateStr = date.toLocaleDateString();
|
||||
if (dateStr !== previousDateStr) {
|
||||
isDifferentDay = true;
|
||||
const separatorText = Helpers.getDaySeparatorText(date, dateStr, now, nowStr);
|
||||
msgListContent.push(
|
||||
<div className="px-2 py-1 inline-block day-separator bg-black opacity-50 text-white rounded-full">
|
||||
{t(separatorText.toLowerCase())}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
previousDateStr = dateStr;
|
||||
}
|
||||
|
||||
let showName = false;
|
||||
if (
|
||||
msg.pubkey !== myPub &&
|
||||
(isDifferentDay || (previousFrom && msg.pubkey !== previousFrom))
|
||||
) {
|
||||
msgListContent.push(<div className="from-separator" />);
|
||||
showName = true;
|
||||
}
|
||||
previousFrom = msg.pubkey;
|
||||
msgListContent.push(
|
||||
<PrivateMessage
|
||||
{...msg}
|
||||
showName={showName}
|
||||
selfAuthored={msg.pubkey === myPub}
|
||||
key={`${msg.created_at}${msg.pubkey}`}
|
||||
chatId={this.props.id}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
mainView = (
|
||||
<div
|
||||
className="main-view p-2 h-screen overflow-y-scroll overflow-x-hidden"
|
||||
id="message-view"
|
||||
onScroll={() => this.onMessageViewScroll()}
|
||||
>
|
||||
<div id="message-list">
|
||||
{msgListContent}
|
||||
<div className="italic my-2 text-neutral-500">{t('dm_privacy_warning')}</div>
|
||||
</div>
|
||||
<div
|
||||
id="attachment-preview"
|
||||
className="attachment-preview"
|
||||
style={{ display: 'none' }}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return mainView;
|
||||
}
|
||||
|
||||
renderMsgForm() {
|
||||
return this.props.id && this.props.id.length > 20 ? (
|
||||
<>
|
||||
<div id="scroll-down-btn" style={{ display: 'none' }} onClick={() => this.scrollDown()}>
|
||||
<ChevronDownIcon width="24" />
|
||||
</div>
|
||||
<ChatMessageForm
|
||||
key={this.props.id}
|
||||
activeChat={this.props.id}
|
||||
onSubmit={() => this.scrollDown()}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{(this.chat && this.chat.name) || 'Messages'}</title>
|
||||
</Helmet>
|
||||
<div
|
||||
id="chat-main"
|
||||
ref={this.ref}
|
||||
className={`${this.props.id ? '' : 'hidden'} flex-1 pb-12`}
|
||||
>
|
||||
{this.renderMainView()} {this.renderMsgForm()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default PrivateChat;
|
@ -1,11 +0,0 @@
|
||||
import Component from '../../../BaseComponent';
|
||||
class MainView extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
render() {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
export default MainView;
|
Loading…
Reference in New Issue
Block a user