summaryrefslogtreecommitdiff
path: root/front/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'front/src/components')
-rw-r--r--front/src/components/NotificationCenter.tsx192
-rw-r--r--front/src/components/composer/Composer.tsx29
-rw-r--r--front/src/components/layout/Sidebar.tsx27
-rw-r--r--front/src/components/post/Footer.tsx12
-rw-r--r--front/src/components/post/Reactions.tsx20
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} />;
}