MainPage.tsx (28007B)
1 import { Brush, Pencil, PersonStanding, Search, Settings, User, X } from 'lucide-react'; 2 import Avatar from '../components/avatar/avatar'; 3 import ChatInput from '../components/input/chat/input'; 4 import RoomList, { Section } from '../components/roomList/roomList'; 5 import './MainPage.scss'; 6 import { useProfile, useRoom, useRooms, useSpaces } from '../app/sdk/client'; 7 import { Room } from '../app/sdk/room'; 8 import { FC, memo, useCallback, useContext, useEffect, useState } from 'react'; 9 import { MatrixContext } from '../app/sdk/client'; 10 import { useLocation, useParams } from 'react-router-dom'; 11 import MessageEvent from '../components/events/messageEvent'; 12 import UnknownEvent, { RedactedEvent, UndecryptableEvent } from '../components/events/unknownEvent'; 13 import MemberEvent from '../components/events/memberEvent'; 14 import { IRoomEvent, IRoomMemberEvent } from '../app/sdk/api/events'; 15 import Linkify from 'linkify-react'; 16 import { OnlineState } from '../app/sdk/api/otherEnums'; 17 import { Virtuoso } from 'react-virtuoso'; 18 import ReactModal from 'react-modal'; 19 import { SDKError } from '../app/sdk/utils'; 20 21 type ChatViewProps = { 22 /** 23 * The roomID of the room to display 24 * If the roomID is empty, the ChatView will be empty 25 * If the roomID is invalid, the ChatView will be empty 26 * If the roomID is valid, the ChatView will display the room 27 * If the roomID is valid, but the room is not joined, the ChatView will display a placeholder 28 * If the roomID is valid, but the room is not loaded, the ChatView will display a placeholder 29 */ 30 room?: Room, 31 id?: string 32 }; 33 34 const ChatView: FC<ChatViewProps> = memo(({ room, id }) => { 35 const client = useContext(MatrixContext); 36 const [events, setEvents] = useState<IRoomEvent[]>([]); 37 const [eventsFull, setEventsFull] = useState<IRoomEvent[]>([]); 38 const { pathname } = useLocation(); 39 const [previousPathname, setPreviousPathname] = useState<string | undefined>(undefined); 40 const [firstItemIndex, setFirstItemIndex] = useState<number | undefined>(undefined) 41 42 const decryptEvents = async (index: number, event: IRoomEvent, eventsFull: IRoomEvent[]) => { 43 let previousEvent = eventsFull?.[index - 1]; 44 const previousEventIsFromSameSender = previousEvent?.sender === event.sender; 45 // Check if event is redacted 46 const redaction = eventsFull?.find((e) => { 47 return e.type === "m.room.redaction" && e.redacts === event.event_id; 48 }); 49 const redacted = redaction !== undefined; 50 let redacted_because = undefined; 51 let redaction_id = undefined; 52 if (redacted) { 53 redaction_id = redaction?.event_id; 54 if (redaction.content.reason) { 55 redacted_because = redaction.content.reason; 56 } 57 } 58 59 // Decrypt the event if it is encrypted 60 if (event.type === "m.room.encrypted" && room?.roomID) { 61 try { 62 const decrypted_event = await client.decryptRoomEvent(room.roomID, event); 63 if (decrypted_event instanceof SDKError) { 64 // TODO: Show proper error instead 65 return { 66 ...event, 67 unsigned: { 68 ...event.unsigned, 69 undecryptable: true, 70 key: event.event_id, 71 hasPreviousEvent: previousEventIsFromSameSender 72 } 73 } 74 } 75 if (decrypted_event) { 76 event = JSON.parse(decrypted_event.event) as IRoomEvent; 77 if (event.content["m.new_content"]) { 78 event.content.body = event.content["m.new_content"].body; 79 event.content.formatted_body = event.content["m.new_content"].formatted_body; 80 event.content.format = event.content["m.new_content"].format; 81 } 82 } else { 83 if (redacted) { 84 return { 85 ...event, 86 unsigned: { 87 ...event.unsigned, 88 redacted: true, 89 redacted_because: redacted_because, 90 hasPreviousEvent: previousEventIsFromSameSender, 91 redaction_id: redaction_id 92 } 93 } 94 } 95 return { 96 ...event, 97 unsigned: { 98 ...event.unsigned, 99 undecryptable: true, 100 key: event.event_id, 101 hasPreviousEvent: previousEventIsFromSameSender 102 } 103 } 104 } 105 } catch (e: any) { 106 if (redacted) { 107 return { 108 ...event, 109 unsigned: { 110 ...event.unsigned, 111 redacted: true, 112 redacted_because: redacted_because, 113 hasPreviousEvent: previousEventIsFromSameSender, 114 redaction_id: redaction_id 115 } 116 } 117 } 118 return { 119 ...event, 120 unsigned: { 121 ...event.unsigned, 122 undecryptable: true, 123 hasPreviousEvent: previousEventIsFromSameSender 124 } 125 } 126 } 127 } 128 129 return event; 130 } 131 132 useEffect(() => { 133 if (previousPathname !== pathname) { 134 console.log("Resetting events because we changed rooms"); 135 setEvents([]); 136 setEventsFull([]); 137 setFirstItemIndex(0); 138 setPreviousPathname(pathname); 139 } 140 141 if ((eventsFull.length === 0) && room) { 142 const eventsAll = room?.getEvents().filter((event, index, self) => { 143 return self.findIndex(e => e.event_id === event.event_id) === index; 144 }).sort((a, b) => { 145 return b.origin_server_ts - a.origin_server_ts; 146 }).reverse(); 147 148 Promise.all(eventsAll.map(async (event, index) => { 149 return await decryptEvents(index, event, eventsAll); 150 })).then((eventsRaw: IRoomEvent[]) => { 151 const no_relations = eventsRaw.filter(event => event.type !== "m.reaction" && 152 event.type !== "m.room.redaction" && 153 event.content["m.relates_to"]?.["rel_type"] !== "m.replace" 154 ); 155 if (no_relations.length > 0) { 156 const events = [...no_relations]; 157 setEventsFull(() => [...eventsRaw]); 158 setEvents(() => events); 159 setFirstItemIndex(events.length); 160 } 161 }) 162 } 163 }, [room, events, setEvents, eventsFull, setEventsFull, pathname, previousPathname, firstItemIndex, setFirstItemIndex]) 164 165 166 useEffect(() => { 167 if (room) { 168 // Listen for event updates 169 const listenForEvents = (eventsListened: IRoomEvent[]) => { 170 const eventsAll = eventsListened.filter((event, index, self) => { 171 return self.findIndex(e => e.event_id === event.event_id) === index; 172 }).sort((a, b) => { 173 return b.origin_server_ts - a.origin_server_ts; 174 }).reverse(); 175 176 Promise.all(eventsAll.map(async (event, index) => { 177 return await decryptEvents(index, event, eventsAll); 178 })).then((eventsRaw: IRoomEvent[]) => { 179 const no_relations = eventsRaw.filter(event => event.type !== "m.reaction" && 180 event.type !== "m.room.redaction" && 181 event.content["m.relates_to"]?.["rel_type"] !== "m.replace" 182 ); 183 184 185 // If there are no events or no changes, do nothing 186 if (no_relations.length > 0 && events.length > 0) { 187 const newEvents = [...no_relations]; 188 if (eventsRaw.length !== eventsFull.length || newEvents.length !== events.length) { 189 setEventsFull(() => [...eventsRaw]); 190 setEvents(() => newEvents); 191 } 192 } 193 }) 194 }; 195 console.log("Adding listener for events") 196 room.on("events", listenForEvents); 197 return () => { 198 console.log("Removing listener for events") 199 room.off("events", listenForEvents); 200 } 201 } 202 }, [room, eventsFull, events, setEventsFull, setEvents, pathname]) 203 204 const renderEventPure = useCallback((_index: number, event: IRoomEvent) => { 205 const index = eventsFull.findIndex(e => e.event_id === event.event_id); 206 if (event.unsigned?.redacted) { 207 return (<RedactedEvent event={event} redacted_because={event.unsigned.redacted_because} key={event.unsigned.redaction_id} room={room} hasPreviousEvent={event.unsigned.hasPreviousEvent} />) 208 } 209 210 if (event.unsigned?.undecryptable) { 211 return (<UndecryptableEvent key={event.event_id} event={event} hasPreviousEvent={event.unsigned.hasPreviousEvent} room={room} />) 212 } 213 214 let previousEvent = eventsFull?.[index - 1]; 215 const previousEventIsFromSameSender = previousEvent?.sender === event.sender; 216 217 let previousEventType = previousEvent?.type; 218 219 // Make a list of events which are reactions for the current event we want to render 220 const reactions = eventsFull?.filter((e) => { 221 return e.type === "m.reaction" && e.content["m.relates_to"].event_id === event.event_id; 222 }); 223 224 // Check if event is redacted 225 const redaction = eventsFull?.find((e) => { 226 return e.type === "m.room.redaction" && e.redacts === event.event_id; 227 }); 228 const redacted = redaction !== undefined; 229 let redacted_because = undefined; 230 let redaction_id = undefined; 231 if (redacted) { 232 redaction_id = redaction?.event_id; 233 if (redaction.content.reason) { 234 redacted_because = redaction.content.reason; 235 } 236 } 237 238 // Check if there is an edit (m.relates_to with rel_type of "m.replace") 239 const edit = eventsFull?.find((e) => { 240 if (!e.content["m.relates_to"]) { 241 return false; 242 } 243 return e.content["m.relates_to"].rel_type === "m.replace" && e.content["m.relates_to"].event_id === event.event_id; 244 }); 245 246 // If there is an edit, use the edited event instead of the original event 247 if (edit) { 248 event = edit; 249 if (edit.content["m.new_content"]) { 250 event.content.body = edit.content["m.new_content"].body; 251 event.content.formatted_body = edit.content["m.new_content"].formatted_body; 252 event.content.format = edit.content["m.new_content"].format; 253 } 254 } 255 256 return ( 257 <div className='max-w-[130ch]'> 258 {renderEvent(event, previousEventIsFromSameSender, previousEventType, reactions, redacted, redacted_because, redaction_id, room)} 259 </div> 260 ) 261 }, [eventsFull]) 262 263 264 if (events.length === 0 || !room || eventsFull.length === 0) { 265 return ( 266 <></> 267 ) 268 } 269 270 return ( 271 <Virtuoso 272 id={id} 273 alignToBottom 274 className='flex overflow-y-auto overflow-x-hidden scrollbarSmall' 275 data={events} 276 firstItemIndex={firstItemIndex} 277 initialTopMostItemIndex={events.length - 1} 278 overscan={200} 279 itemContent={renderEventPure} 280 components={{ Header }} 281 followOutput={(isAtBottom: boolean) => { 282 if (isAtBottom) { 283 return 'smooth' 284 } else { 285 return false 286 } 287 }} 288 /> 289 ); 290 }); 291 292 293 const Header = () => { 294 return ( 295 <div 296 style={{ 297 padding: '2rem', 298 display: 'flex', 299 justifyContent: 'center', 300 }} 301 > 302 Loading... 303 </div> 304 ) 305 } 306 307 // Render events based on the event type and content 308 const renderEvent = (event: IRoomEvent, previousEventIsFromSameSender: boolean, previousEventType: string, reactions: IRoomEvent[], redacted: boolean, redacted_because: string, redaction_id?: string, room?: Room,) => { 309 if (redacted) { 310 return (<RedactedEvent event={event} redacted_because={redacted_because} room={room} hasPreviousEvent={previousEventIsFromSameSender} key={redaction_id} />) 311 } 312 switch (event.type) { 313 case "m.room.message": 314 return <MessageEvent reactions={reactions} event={event} room={room} key={event.event_id} hasPreviousEvent={previousEventIsFromSameSender && previousEventType === "m.room.message"} /> 315 case "m.room.member": 316 return <MemberEvent event={event as IRoomMemberEvent} key={event.event_id} /> 317 default: 318 return <UnknownEvent event={event} key={event.event_id} /> 319 } 320 } 321 322 const MainPage = memo(() => { 323 const profile = useProfile(); 324 const spacesWithRooms = useSpaces(); 325 const rooms = useRooms(); 326 const client = useContext(MatrixContext); 327 const params = useParams(); 328 const room = useRoom(decodeURIComponent(params.roomIdOrAlias || "")); 329 client.setCurrentRoom(params.roomIdOrAlias ? decodeURIComponent(params.roomIdOrAlias) : undefined) 330 331 const [showSettings, setShowSettings] = useState(false); 332 const [searchTerm, setSearchTerm] = useState<string | null>(null); 333 334 // Filter toplevel spaces. 335 // A toplevel space is a space that is not a child of another space. 336 // We can not rely only on the parent. We need to check in both directions. 337 const toplevelSpaces = [...spacesWithRooms].filter(({ spaceRoom }) => { 338 const not_tombstoned = !spaceRoom.isTombstoned(); 339 const not_a_child = ![...spacesWithRooms].some(({ children: otherChildren }) => { 340 return [...otherChildren].some(room => room.roomID === spaceRoom.roomID); 341 }); 342 // Also check if there are no parents set 343 const no_parents = spaceRoom.getSpaceParentIDs().length === 0; 344 return not_a_child && no_parents && not_tombstoned; 345 }); 346 347 // Filter rooms which are not part of any space and are not a space. 348 // A room is not part of any space if it is not a child of any space. 349 // A room is not a space if it has not any space as parent. 350 const leftOverRooms = [...rooms].filter(room => { 351 const not_tombstoned = !room.isTombstoned(); 352 const not_a_child = ![...spacesWithRooms].some(({ children }) => { 353 return [...children].some(otherRoom => otherRoom.roomID === room.roomID); 354 }); 355 const no_parents = room.getSpaceParentIDs().length === 0; 356 const not_a_space = !room.isSpace(); 357 return not_a_child && no_parents && not_a_space && not_tombstoned; 358 }).sort((a, b) => { 359 // Sort rooms by sliding sync list order of overview list, 360 361 // Get the index of the room in the sync list. 362 const a_index = a.windowPos["overview"]; 363 const b_index = b.windowPos["overview"]; 364 365 // If the room is not in the sync list, it will be at the end of the list. 366 // This is the same as the index being -1. 367 // So we need to check for that. 368 if (a_index === -1) { 369 return 1; 370 } 371 if (b_index === -1) { 372 return -1; 373 } 374 375 // If the room is in the sync list, we can compare the indexes. 376 return a_index - b_index; 377 }); 378 379 380 // Generate a list of sections. 381 // Each section apart from special toplevel ones is a space. 382 // Each space has a list of rooms and subsections. 383 // Each subsection has a list of rooms and subsections. 384 // Subsections can nest infinitely. 385 // Rooms are always within a section. 386 // A section represents a space. 387 // If a room is not within a space it is in the toplevel section "Other" which is at the end of the list. 388 // The toplevel section "Other" is always present. 389 // The toplevel section "Other" is always the last section. 390 const sections = toplevelSpaces.map(space => { 391 const rooms = [...space.children].filter(room => !room.isSpace() && !room.isTombstoned() && !room.isDM()).sort((a, b) => { 392 // Sort rooms by sliding sync list order of the spaces list, 393 394 // Get the index of the room in the sync list. 395 const a_index = a.windowPos[space.spaceRoom.roomID]; 396 const b_index = b.windowPos[space.spaceRoom.roomID]; 397 398 // If the room is not in the sync list, it will be at the end of the list. 399 // This is the same as the index being -1. 400 // So we need to check for that. 401 if (a_index === -1) { 402 return 1; 403 } 404 if (b_index === -1) { 405 return -1; 406 } 407 408 // If the room is in the sync list, we can compare the indexes. 409 return a_index - b_index; 410 }).filter(room => { 411 if (searchTerm) { 412 return room.getName().toLowerCase().includes(searchTerm.toLowerCase()) 413 } else { 414 return true; 415 } 416 }).map(room => { 417 return { 418 roomID: room.roomID, 419 displayname: room.getName(), 420 avatarUrl: room.getAvatarURL(), 421 dm: room.isDM(), 422 online: room.presence, 423 } 424 }); 425 426 const generateSubsections = (subspace: Room): Section | undefined => { 427 const subspaceMeta = [...spacesWithRooms].find(space => space.spaceRoom.roomID === subspace.roomID); 428 if (subspaceMeta) { 429 const rooms = [...subspaceMeta?.children].filter(room => !room.isSpace() && !room.isTombstoned() && !room.isDM()).filter(room => { 430 if (searchTerm) { 431 return room.getName().toLowerCase().includes(searchTerm.toLowerCase()) 432 } else { 433 return true; 434 } 435 }).map(room => { 436 return { 437 roomID: room.roomID, 438 displayname: room.getName(), 439 avatarUrl: room.getAvatarURL(), 440 dm: room.isDM(), 441 online: room.presence, 442 } 443 }); 444 445 const subsections = [...subspaceMeta?.children] 446 .filter(room => room.isSpace() && !room.isTombstoned()).map(generateSubsections) 447 .filter(section => section !== undefined) as Section[]; 448 if (searchTerm && rooms.length === 0 && subsections.length == 0) { 449 return undefined; 450 } 451 452 return { 453 sectionName: subspace.getName(), 454 rooms: rooms, 455 roomID: subspace.roomID, 456 subsections: subsections, 457 } 458 } 459 } 460 if (searchTerm && rooms.length === 0) { 461 return undefined; 462 } 463 464 // Its a little weird sicne there are no children attached to the room object. Only to spacesWithRooms. 465 // Each subsection can have further subsections and rooms. 466 return { 467 sectionName: space.spaceRoom.getName(), 468 rooms: rooms, 469 roomID: space.spaceRoom.roomID, 470 subsections: [...space.children] 471 .filter(room => room.isSpace()) 472 .map(generateSubsections).filter(section => section !== undefined), 473 } as Section; 474 }).filter(section => section !== undefined) as Section[]; 475 476 // Add the toplevel section "Other" to the end of the list. 477 const otherRooms = leftOverRooms.filter(room => !room.isSpace() && !room.isDM()).filter(room => { 478 if (searchTerm) { 479 return room.getName().toLowerCase().includes(searchTerm.toLowerCase()) 480 } else { 481 return true; 482 } 483 }).map(room => { 484 return { 485 roomID: room.roomID, 486 displayname: room.getName(), 487 avatarUrl: room.getAvatarURL(), 488 dm: room.isDM(), 489 online: room.presence, 490 } 491 }); 492 493 const dmRooms = leftOverRooms.filter(room => !room.isSpace() && room.isDM()).filter(room => { 494 if (searchTerm) { 495 return room.getName().toLowerCase().includes(searchTerm.toLowerCase()) 496 } else { 497 return true; 498 } 499 }).map(room => { 500 return { 501 roomID: room.roomID, 502 displayname: room.getName(), 503 avatarUrl: room.getAvatarURL(), 504 dm: room.isDM(), 505 online: room.presence, 506 } 507 }); 508 509 510 // Check and print if otherRooms has duplicates. 511 const otherRoomsIDs = otherRooms.map(room => room.roomID); 512 const otherRoomsDuplicates = otherRoomsIDs.filter((id, index) => otherRoomsIDs.indexOf(id) !== index); 513 if (otherRoomsDuplicates.length > 0) { 514 console.error('otherRooms has duplicates', otherRoomsDuplicates); 515 } 516 517 const linkifyOptions = { 518 defaultProtocol: "https", 519 rel: "noopener", 520 target: "_blank", 521 className: "text-blue-500 hover:text-blue-700 active:text-blue-700 visited:text-blue-500" 522 } 523 524 return <div id='main-container'> 525 <div id='sidebar'> 526 <div id="user-info"> 527 <Avatar displayname={profile.displayname || client.mxid!} avatarUrl={profile?.avatar_url} dm={false} online={OnlineState.Unknown} isBot={false} /> 528 <div id='username-container'> 529 530 <span>{profile?.displayname}</span> 531 <Settings size={28} stroke='unset' className='stroke-slate-600 rounded-full hover:bg-slate-300 p-1 cursor-pointer' onClick={() => { setShowSettings(true) }} /> 532 </div> 533 <div id='search-container'> 534 <label className='flex relative flex-row items-center justify-start hover:bg-slate-400/25 bg-slate-400/50 flex-1 rounded-lg group'> 535 <Search size={20} stroke='unset' className='stroke-slate-600 absolute top-2 left-2' /> 536 <input className='placeholder:text-slate-600 bg-transparent py-2 rounded-lg pl-8 group-hover:outline outline-slate-700 w-full outline-2 text-black' placeholder='Search' onInput={(e) => { 537 e.preventDefault(); 538 const value = e.currentTarget.value; 539 if (value === "") { 540 setSearchTerm(null); 541 } else { 542 setSearchTerm(value); 543 } 544 }} /> 545 </label> 546 </div> 547 </div> 548 <RoomList sections={sections} rooms={otherRooms} dmRooms={dmRooms} /> 549 </div> 550 { 551 room ? <> 552 <div className="room-wrapper"></div> 553 <div id='room-info'> 554 <Avatar displayname={room.getName()} avatarUrl={room.getAvatarURL()} dm={room.isDM()} online={room.presence} isBot={false} /> 555 <div className='flex mr-2'> 556 <h1 id='room-name'>{room.getName()}</h1> 557 <Linkify options={linkifyOptions} as='p' className="ml-2 text-slate-700 dark:text-slate-400 font-normal text-base line-clamp-2 text-ellipsis">{room.getTopic()}</Linkify> 558 </div> 559 </div> 560 <ChatView id='chat-view' room={room} /> 561 <ChatInput id='chat-editor' namespace='Editor' room={room} /> 562 </> : <></> 563 } 564 565 <ReactModal 566 isOpen={showSettings} 567 id="modal" 568 overlayClassName="fixed top-0 bottom-0 right-0 left-0 bg-slate-700/75" 569 onRequestClose={() => { setShowSettings(false) }} 570 > 571 <SettingsView onClose={() => { setShowSettings(false) }} /> 572 </ReactModal> 573 </div > 574 }) 575 576 export default MainPage; 577 578 type SettingsViewProps = { 579 onClose: () => void 580 } 581 582 const SettingsView: FC<SettingsViewProps> = memo(({ onClose }) => { 583 const [currentSettingsView, setCurrentSettingsView] = useState("profile"); 584 return ( 585 <> 586 <X id="modal-cross" size={28} stroke='unset' onClick={onClose}>Close Modal</X> 587 <nav id="modal-sidebar"> 588 <button className={'p-1 bg-white w-full text-left hover:bg-slate-200 rounded flex items-center gap-1 ' + (currentSettingsView === "profile" ? '!bg-slate-200' : '')} onClick={() => { setCurrentSettingsView("profile") }}> 589 <User size={28} stroke='unset' className='stroke-slate-600 p-1' /> Profile 590 </button> 591 <button className={'p-1 bg-white w-full text-left hover:bg-slate-200 rounded flex items-center gap-1 ' + (currentSettingsView === "design" ? '!bg-slate-200' : '')} onClick={() => { setCurrentSettingsView("design") }}> 592 <Brush size={28} stroke='unset' className='stroke-slate-600 p-1' /> Design 593 </button> 594 </nav> 595 <div id='modal-content'> 596 { 597 currentSettingsView === "profile" ? 598 <SettingsProfileView /> : 599 <SettingsDesignView /> 600 } 601 602 </div> 603 </> 604 ); 605 }); 606 607 608 const SettingsProfileView = memo(() => { 609 const client = useContext(MatrixContext); 610 const profile = useProfile(); 611 const [displayname, setDisplayname] = useState(profile.displayname); 612 613 return ( 614 <div id="profile-view"> 615 <h1 id="title"><User size={28} stroke='unset' className='stroke-slate-600 p-1' /> Profile</h1> 616 617 <div id="inputs"> 618 <div id='displayname-container'> 619 <span className='text-slate-700 font-semibold'>Displayname</span> 620 <label> 621 <Pencil size={20} stroke='unset' className='stroke-slate-600 absolute top-2 left-2' /> 622 <input value={displayname} onInput={(e) => { 623 e.preventDefault(); 624 const value = e.currentTarget.value; 625 setDisplayname(value); 626 }} /> 627 </label> 628 </div> 629 <div id='matrixid'> 630 <span className='text-slate-700 font-semibold'>MatrixID</span> 631 <label> 632 <PersonStanding size={20} stroke='unset' className='stroke-slate-600 absolute top-2 left-2' /> 633 <input disabled placeholder={client.mxid} /> 634 </label> 635 </div> 636 </div> 637 <div id="avatar"> 638 <Avatar size='14rem' displayname={profile.displayname || client.mxid!} avatarUrl={profile?.avatar_url} dm={false} online={OnlineState.Unknown} isBot={false} /> 639 </div> 640 </div> 641 ) 642 }) 643 644 const SettingsDesignView = memo(() => { 645 return ( 646 <> 647 <h1 className='text-xl font-bold text-black flex items-center'><Brush size={28} stroke='unset' className='stroke-slate-600 p-1' /> Design</h1> 648 </> 649 ) 650 })