We use cookies to enhance your experience on the site
CodeWorlds

Messages and Threads - Conversations

Slack is primarily about messages - chat in channels, threads for organized discussions, and reactions.

Message Component

1'use client';
2
3import { useState } from 'react';
4
5interface MessageItemProps {
6  message: Message;
7  author: Member;
8  currentUserId: string;
9  onReact: (messageId: string, emoji: string) => void;
10  onReply: (messageId: string) => void;
11  onEdit: (messageId: string, newContent: string) => void;
12  onDelete: (messageId: string) => void;
13}
14
15export default function MessageItem({
16  message,
17  author,
18  currentUserId,
19  onReact,
20  onReply,
21  onEdit,
22  onDelete
23}: MessageItemProps) {
24  const [showActions, setShowActions] = useState(false);
25  const [isEditing, setIsEditing] = useState(false);
26  const [editContent, setEditContent] = useState(message.content);
27
28  const isOwnMessage = author.id === currentUserId;
29
30  const formatTime = (date: Date) => {
31    const now = new Date();
32    const isToday = date.toDateString() === now.toDateString();
33
34    if (isToday) {
35      return date.toLocaleTimeString('en-US', {
36        hour: '2-digit',
37        minute: '2-digit'
38      });
39    }
40    return date.toLocaleDateString('en-US', {
41      month: 'short',
42      day: 'numeric'
43    }) + ' at ' + date.toLocaleTimeString('en-US', {
44      hour: '2-digit',
45      minute: '2-digit'
46    });
47  };
48
49  const handleSaveEdit = () => {
50    if (editContent.trim() && editContent !== message.content) {
51      onEdit(message.id, editContent);
52    }
53    setIsEditing(false);
54  };
55
56  return (
57    <div
58      className="group hover:bg-gray-50 px-6 py-2 relative"
59      onMouseEnter={() => setShowActions(true)}
60      onMouseLeave={() => setShowActions(false)}
61    >
62      <div className="flex gap-3">
63        {/* Avatar */}
64        <img
65          src={author.avatar}
66          alt={author.name}
67          className="w-10 h-10 rounded mt-1"
68        />
69
70        <div className="flex-1 min-w-0">
71          {/* Header */}
72          <div className="flex items-baseline gap-2 mb-1">
73            <span className="font-bold text-gray-900">{author.name}</span>
74            <span className="text-xs text-gray-500">{formatTime(message.timestamp)}</span>
75            {message.isEdited && (
76              <span className="text-xs text-gray-500">(edited)</span>
77            )}
78            {message.isPinned && (
79              <span className="text-xs">📌</span>
80            )}
81          </div>
82
83          {/* Content */}
84          {isEditing ? (
85            <div>
86              <textarea
87                value={editContent}
88                onChange={(e) => setEditContent(e.target.value)}
89                className="w-full p-2 border rounded resize-none"
90                rows={3}
91                autoFocus
92              />
93              <div className="flex gap-2 mt-2">
94                <button
95                  onClick={handleSaveEdit}
96                  className="px-4 py-1.5 bg-green-600 text-white rounded text-sm hover:bg-green-700"
97                >
98                  Save
99                </button>
100                <button
101                  onClick={() => {
102                    setEditContent(message.content);
103                    setIsEditing(false);
104                  }}
105                  className="px-4 py-1.5 border rounded text-sm hover:bg-gray-50"
106                >
107                  Cancel
108                </button>
109              </div>
110            </div>
111          ) : (
112            <>
113              <p className="text-gray-900 break-words whitespace-pre-wrap">
114                {message.content}
115              </p>
116
117              {/* Attachments */}
118              {message.attachments && message.attachments.length > 0 && (
119                <div className="mt-2 space-y-2">
120                  {message.attachments.map(attachment => (
121                    <div
122                      key={attachment.id}
123                      className="border rounded p-3 hover:bg-gray-50 cursor-pointer"
124                    >
125                      <div className="flex items-center gap-3">
126                        {attachment.type === 'image' ? (
127                          <img
128                            src={attachment.url}
129                            alt={attachment.name}
130                            className="max-w-sm max-h-64 rounded"
131                          />
132                        ) : (
133                          <>
134                            <span className="text-3xl">📄</span>
135                            <div>
136                              <p className="font-medium text-blue-600">
137                                {attachment.name}
138                              </p>
139                              <p className="text-xs text-gray-500">
140                                {(attachment.size / 1024).toFixed(1)} KB
141                              </p>
142                            </div>
143                          </>
144                        )}
145                      </div>
146                    </div>
147                  ))}
148                </div>
149              )}
150
151              {/* Reactions */}
152              {message.reactions.length > 0 && (
153                <div className="flex flex-wrap gap-1 mt-2">
154                  {message.reactions.map((reaction, idx) => (
155                    <button
156                      key={idx}
157                      onClick={() => onReact(message.id, reaction.emoji)}
158                      className="px-2 py-1 border rounded-full hover:border-blue-500 text-sm flex items-center gap-1"
159                    >
160                      <span>{reaction.emoji}</span>
161                      <span className="text-xs font-semibold">
162                        {reaction.count}
163                      </span>
164                    </button>
165                  ))}
166                  <button className="px-2 py-1 border rounded-full hover:border-blue-500 text-sm opacity-60 hover:opacity-100">
167                    +
168                  </button>
169                </div>
170              )}
171
172              {/* Thread Replies */}
173              {message.replyCount > 0 && (
174                <button
175                  onClick={() => onReply(message.id)}
176                  className="mt-2 text-blue-600 hover:underline text-sm font-semibold flex items-center gap-2"
177                >
178                  <span>💬</span>
179                  {message.replyCount} {message.replyCount === 1 ? 'reply' : 'replies'}
180                </button>
181              )}
182            </>
183          )}
184        </div>
185      </div>
186
187      {/* Hover Actions */}
188      {showActions && !isEditing && (
189        <div className="absolute top-0 right-6 bg-white border rounded shadow-lg flex items-center">
190          <button
191            onClick={() => onReact(message.id, '👍')}
192            className="p-2 hover:bg-gray-100"
193            title="Add reaction"
194          >
195            😊
196          </button>
197          <button
198            onClick={() => onReply(message.id)}
199            className="p-2 hover:bg-gray-100"
200            title="Reply in thread"
201          >
202            💬
203          </button>
204          {isOwnMessage && (
205            <>
206              <button
207                onClick={() => setIsEditing(true)}
208                className="p-2 hover:bg-gray-100"
209                title="Edit message"
210              >
211                ✏️
212              </button>
213              <button
214                onClick={() => onDelete(message.id)}
215                className="p-2 hover:bg-gray-100 text-red-600"
216                title="Delete message"
217              >
218                🗑️
219              </button>
220            </>
221          )}
222          <button className="p-2 hover:bg-gray-100" title="More">
223            •••
224          </button>
225        </div>
226      )}
227    </div>
228  );
229}

Message Input

1'use client';
2
3import { useState, useRef } from 'react';
4
5interface MessageInputProps {
6  channelName: string;
7  onSend: (content: string, attachments?: File[]) => void;
8}
9
10export default function MessageInput({
11  channelName,
12  onSend
13}: MessageInputProps) {
14  const [content, setContent] = useState('');
15  const [attachments, setAttachments] = useState<File[]>([]);
16  const fileInputRef = useRef<HTMLInputElement>(null);
17
18  const handleSubmit = (e: React.FormEvent) => {
19    e.preventDefault();
20
21    if (content.trim() || attachments.length > 0) {
22      onSend(content, attachments);
23      setContent('');
24      setAttachments([]);
25    }
26  };
27
28  const handleKeyDown = (e: React.KeyboardEvent) => {
29    if (e.key === 'Enter' && !e.shiftKey) {
30      e.preventDefault();
31      handleSubmit(e);
32    }
33  };
34
35  return (
36    <div className="p-4 border-t bg-white">
37      <form onSubmit={handleSubmit}>
38        <div className="border rounded-lg overflow-hidden">
39          {/* Attachments Preview */}
40          {attachments.length > 0 && (
41            <div className="bg-gray-50 p-2 border-b flex flex-wrap gap-2">
42              {attachments.map((file, idx) => (
43                <div
44                  key={idx}
45                  className="flex items-center gap-2 bg-white border rounded px-3 py-1"
46                >
47                  <span className="text-sm">{file.name}</span>
48                  <button
49                    type="button"
50                    onClick={() => setAttachments(attachments.filter((_, i) => i !== idx))}
51                    className="text-gray-500 hover:text-gray-700"
52                  >
5354                  </button>
55                </div>
56              ))}
57            </div>
58          )}
59
60          {/* Input */}
61          <textarea
62            value={content}
63            onChange={(e) => setContent(e.target.value)}
64            onKeyDown={handleKeyDown}
65            placeholder={`Message #${channelName}`}
66            className="w-full p-3 resize-none outline-none"
67            rows={3}
68          />
69
70          {/* Toolbar */}
71          <div className="flex items-center justify-between px-3 py-2 bg-gray-50">
72            <div className="flex items-center gap-2">
73              <button
74                type="button"
75                onClick={() => fileInputRef.current?.click()}
76                className="p-1.5 hover:bg-gray-200 rounded"
77                title="Attach file"
78              >
79                📎
80              </button>
81              <button
82                type="button"
83                className="p-1.5 hover:bg-gray-200 rounded"
84                title="Emoji"
85              >
86                😊
87              </button>
88              <button
89                type="button"
90                className="p-1.5 hover:bg-gray-200 rounded"
91                title="Mention"
92              >
93                @
94              </button>
95              <button
96                type="button"
97                className="p-1.5 hover:bg-gray-200 rounded"
98                title="Code block"
99              >
100                {'</>'}
101              </button>
102            </div>
103
104            <button
105              type="submit"
106              disabled={!content.trim() && attachments.length === 0}
107              className="px-4 py-1.5 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-sm font-semibold"
108            >
109              Send
110            </button>
111          </div>
112        </div>
113
114        <input
115          ref={fileInputRef}
116          type="file"
117          multiple
118          className="hidden"
119          onChange={(e) => {
120            if (e.target.files) {
121              setAttachments([...attachments, ...Array.from(e.target.files)]);
122            }
123          }}
124        />
125      </form>
126    </div>
127  );
128}

Thread Panel

1'use client';
2
3interface ThreadPanelProps {
4  parentMessage: Message;
5  parentAuthor: Member;
6  replies: Message[];
7  onClose: () => void;
8  onSendReply: (content: string) => void;
9}
10
11export default function ThreadPanel({
12  parentMessage,
13  parentAuthor,
14  replies,
15  onClose,
16  onSendReply
17}: ThreadPanelProps) {
18  return (
19    <div className="w-96 border-l bg-white flex flex-col h-screen">
20      {/* Header */}
21      <div className="p-4 border-b flex items-center justify-between">
22        <h3 className="font-bold">Thread</h3>
23        <button
24          onClick={onClose}
25          className="hover:bg-gray-100 p-2 rounded"
26        >
2728        </button>
29      </div>
30
31      {/* Parent Message */}
32      <div className="p-4 border-b">
33        <div className="flex gap-3">
34          <img
35            src={parentAuthor.avatar}
36            alt={parentAuthor.name}
37            className="w-10 h-10 rounded"
38          />
39          <div className="flex-1">
40            <div className="font-bold mb-1">{parentAuthor.name}</div>
41            <p className="text-gray-900 whitespace-pre-wrap">
42              {parentMessage.content}
43            </p>
44          </div>
45        </div>
46        <div className="mt-3 text-sm text-gray-600">
47          {replies.length} {replies.length === 1 ? 'reply' : 'replies'}
48        </div>
49      </div>
50
51      {/* Replies */}
52      <div className="flex-1 overflow-y-auto">
53        {replies.map(reply => {
54          return (
55            <div key={reply.id} className="p-4 border-b">
56              <div className="flex gap-3">
57                <img
58                  src={reply.author.avatar}
59                  alt={reply.author.name}
60                  className="w-8 h-8 rounded"
61                />
62                <div className="flex-1">
63                  <div className="flex items-baseline gap-2 mb-1">
64                    <span className="font-bold text-sm">{reply.author.name}</span>
65                    <span className="text-xs text-gray-500">
66                      {reply.timestamp.toLocaleTimeString()}
67                    </span>
68                  </div>
69                  <p className="text-sm text-gray-900">{reply.content}</p>
70                </div>
71              </div>
72            </div>
73          );
74        })}
75      </div>
76
77      {/* Reply Input */}
78      <div className="p-3 border-t">
79        <MessageInput
80          channelName="thread"
81          onSend={onSendReply}
82        />
83      </div>
84    </div>
85  );
86}

Summary 🎓

Message component - avatar, timestamp, content, edited badge ✅ Hover actions - react, reply, edit, delete ✅ Reactions - emoji reactions with counts ✅ Attachments - files and image preview ✅ Message input - multiline, attachments, toolbar ✅ Enter to send - Shift+Enter for new line ✅ Threads - replies in sidebar panel ✅ Thread count - number of replies under message

In the next exercise we'll add @mentions and notifications!

See you next time! 🚀

Go to CodeWorlds