Slack to przede wszystkim messages - chat w kanałach, threads dla organized discussions i reactions.
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}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 >
53 ✕
54 </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}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 >
27 ✕
28 </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 // Render reply messages
55 return (
56 <div key={reply.id} className="p-4 border-b">
57 <div className="flex gap-3">
58 <img
59 src={reply.author.avatar}
60 alt={reply.author.name}
61 className="w-8 h-8 rounded"
62 />
63 <div className="flex-1">
64 <div className="flex items-baseline gap-2 mb-1">
65 <span className="font-bold text-sm">{reply.author.name}</span>
66 <span className="text-xs text-gray-500">
67 {reply.timestamp.toLocaleTimeString()}
68 </span>
69 </div>
70 <p className="text-sm text-gray-900">{reply.content}</p>
71 </div>
72 </div>
73 </div>
74 );
75 })}
76 </div>
77
78 {/* Reply Input */}
79 <div className="p-3 border-t">
80 <MessageInput
81 channelName="thread"
82 onSend={onSendReply}
83 />
84 </div>
85 </div>
86 );
87}✅ Message component - avatar, timestamp, content, edited badge ✅ Hover actions - react, reply, edit, delete ✅ Reactions - emoji reactions with counts ✅ Attachments - files i images preview ✅ Message input - multiline, attachments, toolbar ✅ Enter to send - Shift+Enter for new line ✅ Threads - replies in sidebar panel ✅ Thread count - liczba replies pod message
W następnym ćwiczeniu dodamy @mentions i notifications!
Do zobaczenia! 🚀