diff options
Diffstat (limited to 'front/src/components')
-rw-r--r-- | front/src/components/NotificationCenter.tsx | 192 | ||||
-rw-r--r-- | front/src/components/composer/Composer.tsx | 29 | ||||
-rw-r--r-- | front/src/components/layout/Sidebar.tsx | 27 | ||||
-rw-r--r-- | front/src/components/post/Footer.tsx | 12 | ||||
-rw-r--r-- | front/src/components/post/Reactions.tsx | 20 |
5 files changed, 272 insertions, 8 deletions
diff --git a/front/src/components/NotificationCenter.tsx b/front/src/components/NotificationCenter.tsx new file mode 100644 index 0000000..44a6799 --- /dev/null +++ b/front/src/components/NotificationCenter.tsx @@ -0,0 +1,192 @@ +import { useState } from "react"; +import useLocalState from "@/state/state"; +import Modal from "./modals/Modal"; +import Icon from "./Icon"; +import Avatar from "./Avatar"; +import { useLocation } from "wouter"; +import type { Notification, NotificationType } from "@/types/notifications"; +import "@/styles/NotificationCenter.css"; + +const NotificationCenter = () => { + const [_, navigate] = useLocation(); + const { + notifications, + unreadNotifications, + markNotificationRead, + markAllNotificationsRead, + clearNotifications, + setModal + } = useLocalState((s) => ({ + notifications: s.notifications, + unreadNotifications: s.unreadNotifications, + markNotificationRead: s.markNotificationRead, + markAllNotificationsRead: s.markAllNotificationsRead, + clearNotifications: s.clearNotifications, + setModal: s.setModal + })); + + const [filter, setFilter] = useState<"all" | "unread">("all"); + + const filteredNotifications = filter === "unread" + ? notifications.filter(n => !n.read) + : notifications; + + const handleNotificationClick = (notification: Notification) => { + // Mark as read + if (!notification.read) { + markNotificationRead(notification.id); + } + + // Navigate based on notification type + if (notification.postId) { + // Navigate to post + navigate(`/post/${notification.postId}`); + setModal(null); + } else if (notification.type === "follow" || notification.type === "access_request") { + // Navigate to user profile + navigate(`/feed/${notification.from}`); + setModal(null); + } + }; + + const getNotificationIcon = (type: NotificationType) => { + switch (type) { + case "follow": + case "unfollow": + return "pals"; + case "mention": + case "reply": + return "messages"; + case "repost": + return "repost"; + case "react": + return "emoji"; + case "access_request": + case "access_granted": + return "key"; + default: + return "bell"; + } + }; + + const getNotificationText = (notification: Notification) => { + switch (notification.type) { + case "follow": + return `${notification.from} started following you`; + case "unfollow": + return `${notification.from} unfollowed you`; + case "mention": + return `${notification.from} mentioned you in a post`; + case "reply": + return `${notification.from} replied to your post`; + case "repost": + return `${notification.from} reposted your post`; + case "react": + return `${notification.from} reacted ${notification.reaction || ""} to your post`; + case "access_request": + return `${notification.from} requested access to your feed`; + case "access_granted": + return `${notification.from} granted you access to their feed`; + default: + return notification.message || "New notification"; + } + }; + + const formatTimestamp = (date: Date) => { + const now = new Date(); + const diff = now.getTime() - new Date(date).getTime(); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return "Just now"; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + return new Date(date).toLocaleDateString(); + }; + + return ( + <Modal close={() => setModal(null)}> + <div className="notification-center"> + <div className="notification-header"> + <h2>Notifications</h2> + <div className="notification-actions"> + {unreadNotifications > 0 && ( + <button + className="mark-all-read-btn" + onClick={markAllNotificationsRead} + > + Mark all as read + </button> + )} + {notifications.length > 0 && ( + <button + className="clear-all-btn" + onClick={clearNotifications} + > + Clear all + </button> + )} + </div> + </div> + + <div className="notification-filters"> + <button + className={`filter-btn ${filter === "all" ? "active" : ""}`} + onClick={() => setFilter("all")} + > + All ({notifications.length}) + </button> + <button + className={`filter-btn ${filter === "unread" ? "active" : ""}`} + onClick={() => setFilter("unread")} + > + Unread ({unreadNotifications}) + </button> + </div> + + <div className="notification-list"> + {filteredNotifications.length === 0 ? ( + <div className="no-notifications"> + <Icon name="bell" size={48} color="textMuted" /> + <p>No {filter === "unread" ? "unread " : ""}notifications</p> + </div> + ) : ( + filteredNotifications.map((notification) => ( + <div + key={notification.id} + className={`notification-item ${!notification.read ? "unread" : ""}`} + onClick={() => handleNotificationClick(notification)} + > + <div className="notification-icon"> + <Icon + name={getNotificationIcon(notification.type)} + size={20} + color={!notification.read ? "primary" : "textSecondary"} + /> + </div> + + <div className="notification-content"> + <div className="notification-user"> + <Avatar p={notification.from} size={32} /> + <div className="notification-text"> + <p>{getNotificationText(notification)}</p> + <span className="notification-time"> + {formatTimestamp(notification.timestamp)} + </span> + </div> + </div> + </div> + + {!notification.read && <div className="unread-indicator" />} + </div> + )) + )} + </div> + </div> + </Modal> + ); +}; + +export default NotificationCenter;
\ No newline at end of file diff --git a/front/src/components/composer/Composer.tsx b/front/src/components/composer/Composer.tsx index daa5af6..43d38cd 100644 --- a/front/src/components/composer/Composer.tsx +++ b/front/src/components/composer/Composer.tsx @@ -15,9 +15,11 @@ function Composer({ replying?: Poast; }) { const [loc, navigate] = useLocation(); - const { api, composerData } = useLocalState((s) => ({ + const { api, composerData, addNotification, setComposerData } = useLocalState((s) => ({ api: s.api, composerData: s.composerData, + addNotification: s.addNotification, + setComposerData: s.setComposerData, })); const our = api!.airlock.our!; const [input, setInput] = useState(replying ? `${replying}: ` : ""); @@ -39,7 +41,32 @@ function Composer({ // TODO make it user choosable const res = await api!.addPost(input); if (res) { + // Check for mentions in the post (ship names starting with ~) + const mentions = input.match(/~[a-z-]+/g); + if (mentions) { + mentions.forEach(mention => { + if (mention !== our) { // Don't notify self-mentions + addNotification({ + type: "mention", + from: our, + message: `You mentioned ${mention} in a post`, + }); + } + }); + } + + // If this is a reply, add notification + if (composerData?.type === "reply" && composerData.post?.trill?.author !== our) { + addNotification({ + type: "reply", + from: our, + message: `You replied to ${composerData.post.trill.author}'s post`, + postId: composerData.post.trill.id, + }); + } + setInput(""); + setComposerData(null); // Clear composer data after successful post toast.success("post sent"); navigate(`/feed/${our}`); } diff --git a/front/src/components/layout/Sidebar.tsx b/front/src/components/layout/Sidebar.tsx index d237fb5..c267e2f 100644 --- a/front/src/components/layout/Sidebar.tsx +++ b/front/src/components/layout/Sidebar.tsx @@ -7,10 +7,22 @@ import { ThemeSwitcher } from "@/styles/ThemeSwitcher"; function SlidingMenu() { const [_, navigate] = useLocation(); - const { api } = useLocalState((s) => ({ api: s.api })); + const { api, unreadNotifications, setModal } = useLocalState((s) => ({ + api: s.api, + unreadNotifications: s.unreadNotifications, + setModal: s.setModal + })); + function goto(to: string) { navigate(to); } + + function openNotifications() { + // We'll create this component next + import("../NotificationCenter").then(({ default: NotificationCenter }) => { + setModal(<NotificationCenter />); + }); + } return ( <div id="left-menu"> <div id="logo"> @@ -22,9 +34,16 @@ function SlidingMenu() { <Icon name="home" size={20} /> <div>Home</div> </div> - <div className="opt" role="link" onClick={() => goto(`/hark`)}> - <Icon name="bell" size={20} /> - <div>Activity</div> + <div className="opt notification-item" role="link" onClick={openNotifications}> + <div className="notification-icon-wrapper"> + <Icon name="bell" size={20} /> + {unreadNotifications > 0 && ( + <span className="notification-badge"> + {unreadNotifications > 99 ? "99+" : unreadNotifications} + </span> + )} + </div> + <div>Notifications</div> </div> <hr /> diff --git a/front/src/components/post/Footer.tsx b/front/src/components/post/Footer.tsx index 3e4bbdc..d16f4fc 100644 --- a/front/src/components/post/Footer.tsx +++ b/front/src/components/post/Footer.tsx @@ -13,15 +13,25 @@ function Footer({ poast, refetch }: PostProps) { const [_showMenu, setShowMenu] = useState(false); const [location, navigate] = useLocation(); const [reposting, _setReposting] = useState(false); - const { api, setComposerData, setModal } = useLocalState((s) => ({ + const { api, setComposerData, setModal, addNotification } = useLocalState((s) => ({ api: s.api, setComposerData: s.setComposerData, setModal: s.setModal, + addNotification: s.addNotification, })); const our = api!.airlock.our!; function doReply(e: React.MouseEvent) { e.stopPropagation(); setComposerData({ type: "reply", post: { trill: poast } }); + // Only add notification if replying to someone else's post + if (poast.author !== our) { + addNotification({ + type: "reply", + from: our, + message: `You replied to ${poast.author}'s post`, + postId: poast.id, + }); + } } function doQuote(e: React.MouseEvent) { e.stopPropagation(); diff --git a/front/src/components/post/Reactions.tsx b/front/src/components/post/Reactions.tsx index aabab61..ee40d26 100644 --- a/front/src/components/post/Reactions.tsx +++ b/front/src/components/post/Reactions.tsx @@ -110,9 +110,25 @@ export function stringToReact(s: string) { } export function TrillReactModal({ poast }: { poast: Poast }) { - const { api } = useLocalState(); + const { api, addNotification } = useLocalState((s) => ({ + api: s.api, + addNotification: s.addNotification, + })); + const our = api!.airlock.our!; + async function sendReact(s: string) { - return await api!.addReact(poast.host, poast.id, s); + const result = await api!.addReact(poast.host, poast.id, s); + // Only add notification if reacting to someone else's post + if (result && poast.author !== our) { + addNotification({ + type: "react", + from: our, + message: `You reacted to ${poast.author}'s post`, + reaction: s, + postId: poast.id, + }); + } + return result; } return <ReactModal send={sendReact} />; } |