Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add image attachment support to chat #2149

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b403b78
Add image attachment support
nakajima Mar 22, 2023
528db21
Auto load attachments less than 100mb for convos with people you're f…
nakajima Mar 24, 2023
0702a67
Remove unused import
nakajima Mar 24, 2023
7e0f171
Apply suggestions from code review
nakajima Mar 24, 2023
de19aa7
Undo this
nakajima Mar 24, 2023
b6c7faa
Merge branch 'add-attachment-support' of https://github.com/nakajima/…
nakajima Mar 24, 2023
15cdc7e
Fix error
nakajima Mar 27, 2023
2e12034
Merge branch 'main' of https://github.com/lensterxyz/lenster into add…
nakajima Mar 27, 2023
5cef713
Merge branch 'main' into add-attachment-support
bigint Mar 27, 2023
e3f2ddb
Increase width of loading state
nakajima Mar 28, 2023
1e6401d
Merge branch 'add-attachment-support' of https://github.com/nakajima/…
nakajima Mar 28, 2023
b572285
Merge branch 'main' of https://github.com/lensterxyz/lenster into add…
nakajima Mar 28, 2023
4104570
Use small spinner
nakajima Mar 28, 2023
609c674
Reduce y padding
nakajima Mar 28, 2023
0bd4a7c
Merge branch 'main' into add-attachment-support
bigint Mar 28, 2023
9747043
Merge branch 'main' into add-attachment-support
nakajima Mar 28, 2023
252824e
Undo this
nakajima Mar 28, 2023
f981b26
Merge branch 'add-attachment-support' of https://github.com/nakajima/…
nakajima Mar 28, 2023
a24cdab
Merge branch 'main' into add-attachment-support
bigint Mar 30, 2023
3ae4643
Merge branch 'main' into add-attachment-support
bigint Mar 30, 2023
a8c1993
Merge branch 'main' of https://github.com/lensterxyz/lenster into add…
nakajima Apr 2, 2023
5a9a522
Merge branch 'main' of https://github.com/lensterxyz/lenster into add…
nakajima Apr 11, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"ethers": "5.6.0",
"framer-motion": "^10.8.4",
"graphql": "^16.6.0",
"idb-keyval": "^6.2.0",
"jwt-decode": "^3.1.2",
"lexical": "^0.9.0",
"mixpanel-browser": "^2.45.0",
Expand All @@ -64,6 +65,7 @@
"utils": "*",
"uuid": "^9.0.0",
"wagmi": "^0.12.6",
"xmtp-content-type-remote-attachment": "^1.0.6",
"zod": "^3.21.4",
"zustand": "^4.3.6"
},
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/components/Messages/AttachmentView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Image } from '@components/UI/Image';
import type { Attachment } from 'xmtp-content-type-remote-attachment';

type AttachmentViewProps = {
attachment: Attachment;
};

function isImage(mimeType: string): boolean {
return ['image/png', 'image/jpeg', 'image/gif'].includes(mimeType);
}

function contentFor(attachment: Attachment): JSX.Element {
// The attachment.data gets turned into an object when it's serialized
// via JSON.stringify in the store persistence. This check restores it
// to the correct type.
if (!(attachment.data instanceof Uint8Array)) {
attachment.data = Uint8Array.from(Object.values(attachment.data));
}

const objectURL = URL.createObjectURL(
new Blob([Buffer.from(attachment.data)], {
type: attachment.mimeType
})
);

if (isImage(attachment.mimeType)) {
return <Image className="max-h-48 rounded object-contain" src={objectURL} alt="" />;
}

return (
<a target="_blank" href={objectURL}>
{attachment.filename}
</a>
);
}

const AttachmentView = ({ attachment }: AttachmentViewProps): JSX.Element => {
return contentFor(attachment);
};

export default AttachmentView;
170 changes: 144 additions & 26 deletions apps/web/src/components/Messages/Composer.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,107 @@
import { Input } from '@components/UI/Input';
import { Spinner } from '@components/UI/Spinner';
import useWindowSize from '@components/utils/hooks/useWindowSize';
import { ArrowRightIcon } from '@heroicons/react/outline';
import { ArrowRightIcon, PhotographIcon } from '@heroicons/react/outline';
import { Mixpanel } from '@lib/mixpanel';
import { uploadFileToIPFS } from '@lib/uploadToIPFS';
import { t, Trans } from '@lingui/macro';
import type { ContentTypeId } from '@xmtp/xmtp-js';
import { ContentTypeText } from '@xmtp/xmtp-js';
import { MIN_WIDTH_DESKTOP } from 'data/constants';
import type { FC } from 'react';
import { useEffect, useState } from 'react';
import type { ChangeEvent, FC } from 'react';
import { useEffect, useRef, useState } from 'react';
import toast from 'react-hot-toast';
import { useAttachmentCacheStore, useAttachmentStore } from 'src/store/attachment';
import { useMessagePersistStore } from 'src/store/message';
import { MESSAGES } from 'src/tracking';
import { Button } from 'ui';
import type { Attachment, RemoteAttachment } from 'xmtp-content-type-remote-attachment';
import {
AttachmentCodec,
ContentTypeRemoteAttachment,
RemoteAttachmentCodec
} from 'xmtp-content-type-remote-attachment';

import AttachmentView from './AttachmentView';

interface ComposerProps {
sendMessage: (message: string) => Promise<boolean>;
sendMessage: (content: any, contentType: ContentTypeId) => Promise<boolean>;
conversationKey: string;
disabledInput: boolean;
}

const AttachmentComposerPreview = ({
onDismiss,
attachment
}: {
onDismiss: () => void;
attachment: Attachment;
}): JSX.Element => {
return (
<div className="relative ml-12 inline-block rounded pt-6">
<Button className="absolute top-4 right-4" size="sm" onClick={onDismiss}>
Remove
</Button>
<AttachmentView attachment={attachment} />
</div>
);
};

const Composer: FC<ComposerProps> = ({ sendMessage, conversationKey, disabledInput }) => {
const [message, setMessage] = useState<string>('');
const [sending, setSending] = useState<boolean>(false);
const [attachment, setAttachment] = useState<Attachment | null>(null);
const { width } = useWindowSize();
const unsentMessage = useMessagePersistStore((state) => state.unsentMessages.get(conversationKey));
const setUnsentMessage = useMessagePersistStore((state) => state.setUnsentMessage);
const fileInputRef = useRef<HTMLInputElement>(null);
const addLoadedAttachmentURL = useAttachmentStore((state) => state.addLoadedAttachmentURL);
const cacheAttachment = useAttachmentCacheStore((state) => state.cacheAttachment);

const canSendMessage = !disabledInput && !sending && message.length > 0;
const canSendMessage = !disabledInput && !sending && (message.length > 0 || attachment);

const handleSend = async () => {
if (!canSendMessage) {
return;
}
setSending(true);
const sent = await sendMessage(message);

var sent: boolean;
nakajima marked this conversation as resolved.
Show resolved Hide resolved
if (attachment) {
const encryptedEncodedContent = await RemoteAttachmentCodec.encodeEncrypted(
attachment,
new AttachmentCodec()
);

const file = new File([encryptedEncodedContent.payload], 'XMTPEncryptedContent', {
type: attachment.mimeType
});

const lensterAttachment = await uploadFileToIPFS(file);
const cid = lensterAttachment?.item.replace('ipfs://', '');
const url = `https://${cid}.ipfs.w3s.link`;

const remoteAttachment: RemoteAttachment = {
url: url,
nakajima marked this conversation as resolved.
Show resolved Hide resolved
contentDigest: encryptedEncodedContent.digest,
salt: encryptedEncodedContent.salt,
nonce: encryptedEncodedContent.nonce,
secret: encryptedEncodedContent.secret,
scheme: 'https://',
filename: attachment.filename,
contentLength: attachment.data.byteLength
};

// Since we're sending this, we should always load it
addLoadedAttachmentURL(url);
cacheAttachment(url, attachment);

sent = await sendMessage(remoteAttachment, ContentTypeRemoteAttachment);
} else {
sent = await sendMessage(message, ContentTypeText);
}
if (sent) {
setAttachment(null);
setMessage('');
setUnsentMessage(conversationKey, null);
Mixpanel.track(MESSAGES.SEND);
Expand All @@ -58,27 +126,77 @@ const Composer: FC<ComposerProps> = ({ sendMessage, conversationKey, disabledInp
}
};

const onAttachmentChange = async (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.length) {
const file = e.target.files[0];

const fileReader = new FileReader();
fileReader.addEventListener('load', async function () {
const data = fileReader.result;

if (!(data instanceof ArrayBuffer)) {
return;
}

const attachment: Attachment = {
filename: file.name,
mimeType: file.type,
data: new Uint8Array(data)
};

setAttachment(attachment);
});

fileReader.readAsArrayBuffer(file);
} else {
setAttachment(null);
}
};

function onDismiss() {
setAttachment(null);

const el = fileInputRef?.current;
nakajima marked this conversation as resolved.
Show resolved Hide resolved
if (el) {
el.value = '';
}
}

return (
<div className="flex space-x-4 p-4">
<Input
type="text"
placeholder={t`Type Something`}
value={message}
disabled={disabledInput}
onKeyDown={handleKeyDown}
onChange={(event) => onChangeCallback(event.target.value)}
/>
<Button disabled={!canSendMessage} onClick={handleSend} variant="primary" aria-label="Send message">
<div className="flex items-center space-x-2">
{Number(width) > MIN_WIDTH_DESKTOP ? (
<span>
<Trans>Send</Trans>
</span>
) : null}
{!sending && <ArrowRightIcon className="h-5 w-5" />}
{sending && <Spinner size="sm" className="h-5 w-5" />}
</div>
</Button>
<div className="bg-brand-100/75">
{attachment && <AttachmentComposerPreview onDismiss={onDismiss} attachment={attachment} />}
<div className="flex space-x-4 p-4">
<label className="flex cursor-pointer items-center">
<PhotographIcon className="text-brand-900 h-6 w-5" />
<input
ref={fileInputRef}
type="file"
accept=".png, .jpg, .jpeg, .gif"
className="hidden w-full"
onChange={onAttachmentChange}
/>
</label>

<Input
type="text"
placeholder={t`Type Something`}
value={message}
disabled={disabledInput || attachment != null}
onKeyDown={handleKeyDown}
onChange={(event) => onChangeCallback(event.target.value)}
/>
<Button disabled={!canSendMessage} onClick={handleSend} variant="primary" aria-label="Send message">
<div className="flex items-center space-x-2">
{Number(width) > MIN_WIDTH_DESKTOP ? (
<span>
<Trans>Send</Trans>
</span>
) : null}
{!sending && <ArrowRightIcon className="h-5 w-5" />}
{sending && <Spinner size="sm" className="h-5 w-5" />}
</div>
</Button>
</div>
</div>
);
};
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/Messages/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ const Message: FC<MessageProps> = ({ conversationKey }) => {
className="xs:hidden sm:hidden md:hidden lg:block"
selectedConversationKey={conversationKey}
/>
<GridItemEight className="xs:h-[85vh] xs:mx-2 mb-0 sm:mx-2 sm:h-[76vh] md:col-span-8 md:h-[80vh] xl:h-[84vh]">
<Card className="flex h-full flex-col justify-between">
<GridItemEight className="xs:mx-2 relative mb-0 sm:mx-2 md:col-span-8">
<Card className="flex h-[87vh] flex-col justify-between">
{showLoading ? (
<div className="flex h-full flex-grow items-center justify-center">
<Loader message={t`Loading messages`} />
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/components/Messages/MessageContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Markup from '@components/Shared/Markup';
import type { DecodedMessage } from '@xmtp/xmtp-js';
import type { Profile } from 'lens';
import { ContentTypeRemoteAttachment } from 'xmtp-content-type-remote-attachment';

import RemoteAttachmentPreview from './RemoteAttachmentPreview';

type MessageContentProps = {
message: DecodedMessage;
profile: Profile | undefined;
sentByMe: boolean;
};

const MessageContent = ({ message, profile, sentByMe }: MessageContentProps): JSX.Element => {
function content(): JSX.Element {
if (message.error) {
return <span>{`Error: ${message.error}`}</span>;
nakajima marked this conversation as resolved.
Show resolved Hide resolved
}

if (message.contentType.sameAs(ContentTypeRemoteAttachment)) {
return (
<RemoteAttachmentPreview remoteAttachment={message.content} profile={profile} sentByMe={sentByMe} />
);
} else {
return <Markup>{message.content}</Markup>;
}
}

return content();
};

export default MessageContent;
9 changes: 5 additions & 4 deletions apps/web/src/components/Messages/MessagesList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import Markup from '@components/Shared/Markup';
import { Image } from '@components/UI/Image';
import { EmojiSadIcon } from '@heroicons/react/outline';
import { formatTime } from '@lib/formatTime';
Expand All @@ -14,6 +13,8 @@ import { Card } from 'ui';
import formatHandle from 'utils/formatHandle';
import getAvatar from 'utils/getAvatar';

import MessageContent from './MessageContent';

const isOnSameDay = (d1?: Date, d2?: Date): boolean => {
return dayjs(d1).format('YYYYMMDD') === dayjs(d2).format('YYYYMMDD');
};
Expand Down Expand Up @@ -59,7 +60,7 @@ const MessageTile: FC<MessageTileProps> = ({ message, profile, currentProfile })
'text-md linkify-message block break-words'
)}
>
{message.error ? `Error: ${message.error?.message}` : <Markup>{message.content}</Markup> ?? ''}
<MessageContent message={message} profile={profile} sentByMe={address == message.senderAddress} />
</span>
</div>
</div>
Expand Down Expand Up @@ -149,9 +150,9 @@ const MessagesList: FC<MessageListProps> = ({
});

return (
<div className="flex h-[75%] flex-grow">
<div className="flex flex-grow overflow-y-hidden">
<div className="relative flex h-full w-full pl-4">
<div className="flex h-full w-full flex-col-reverse overflow-y-auto">
<div className="flex h-full w-full flex-col-reverse overflow-y-hidden">
{missingXmtpAuth && <MissingXmtpAuth />}
<span className="flex flex-col-reverse overflow-y-auto overflow-x-hidden">
{messages?.map((msg: DecodedMessage, index) => {
Expand Down
16 changes: 15 additions & 1 deletion apps/web/src/components/Messages/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Image } from '@components/UI/Image';
import { BadgeCheckIcon } from '@heroicons/react/solid';
import { formatTime, getTimeFromNow } from '@lib/formatTime';
import type { DecodedMessage } from '@xmtp/xmtp-js';
import { ContentTypeText } from '@xmtp/xmtp-js';
import clsx from 'clsx';
import type { Profile } from 'lens';
import { useRouter } from 'next/router';
Expand All @@ -10,6 +11,8 @@ import { useAppStore } from 'src/store/app';
import formatHandle from 'utils/formatHandle';
import getAvatar from 'utils/getAvatar';
import isVerified from 'utils/isVerified';
import type { RemoteAttachment } from 'xmtp-content-type-remote-attachment';
import { ContentTypeRemoteAttachment } from 'xmtp-content-type-remote-attachment';

interface PreviewProps {
profile: Profile;
Expand All @@ -18,6 +21,17 @@ interface PreviewProps {
isSelected: boolean;
}

function contentFor(message: DecodedMessage): JSX.Element | string {
if (message.contentType.sameAs(ContentTypeText)) {
return message.content;
} else if (message.contentType.sameAs(ContentTypeRemoteAttachment)) {
const remoteAttachment: RemoteAttachment = message.content;
return <span>{remoteAttachment.filename}</span>;
} else {
return '';
}
}

const Preview: FC<PreviewProps> = ({ profile, message, conversationKey, isSelected }) => {
const router = useRouter();
const currentProfile = useAppStore((state) => state.currentProfile);
Expand Down Expand Up @@ -60,7 +74,7 @@ const Preview: FC<PreviewProps> = ({ profile, message, conversationKey, isSelect
)}
</div>
<span className="lt-text-gray-500 line-clamp-1 break-all text-sm">
{address === message.senderAddress && 'You: '} {message.content}
{address === message.senderAddress && 'You: '} {contentFor(message)}
</span>
</div>
</div>
Expand Down
Loading