diff options
Diffstat (limited to 'front/src')
-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 | ||||
-rw-r--r-- | front/src/logic/api.ts | 2 | ||||
-rw-r--r-- | front/src/pages/Settings.tsx | 38 | ||||
-rw-r--r-- | front/src/pages/User.tsx | 47 | ||||
-rw-r--r-- | front/src/state/state.ts | 58 | ||||
-rw-r--r-- | front/src/styles/NotificationCenter.css | 263 | ||||
-rw-r--r-- | front/src/types/notifications.ts | 28 |
11 files changed, 699 insertions, 17 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} />; } diff --git a/front/src/logic/api.ts b/front/src/logic/api.ts index 148d255..52635e5 100644 --- a/front/src/logic/api.ts +++ b/front/src/logic/api.ts @@ -1,6 +1,6 @@ import Urbit from "urbit-api"; -export const URL = import.meta.env.PROD ? "" : "http://localhost:8081"; +export const URL = import.meta.env.PROD ? "" : "http://localhost:8080"; export async function start(): Promise<Urbit> { const airlock = new Urbit(URL, ""); diff --git a/front/src/pages/Settings.tsx b/front/src/pages/Settings.tsx index 6b6f7bd..cd9eec7 100644 --- a/front/src/pages/Settings.tsx +++ b/front/src/pages/Settings.tsx @@ -6,10 +6,11 @@ import Icon from "@/components/Icon"; import "@/styles/Settings.css"; function Settings() { - const { key, relays, api } = useLocalState((s) => ({ + const { key, relays, api, addNotification } = useLocalState((s) => ({ key: s.key, relays: s.relays, api: s.api, + addNotification: s.addNotification, })); const [newRelay, setNewRelay] = useState(""); const [isAddingRelay, setIsAddingRelay] = useState(false); @@ -78,6 +79,41 @@ function Settings() { </div> <div className="settings-content"> + {/* Notifications Test Section - Remove in production */} + <div className="settings-section"> + <div className="section-header"> + <Icon name="bell" size={20} /> + <h2>Test Notifications</h2> + </div> + <div className="section-content"> + <div className="setting-item"> + <div className="setting-info"> + <label>Test Notification System</label> + <p>Generate test notifications to see how they work</p> + </div> + <div className="setting-control"> + <button + className="test-notification-btn" + onClick={() => { + const types = ["follow", "reply", "react", "mention", "access_request"]; + const randomType = types[Math.floor(Math.random() * types.length)] as any; + addNotification({ + type: randomType, + from: "~sampel-palnet", + message: "This is a test notification", + reaction: randomType === "react" ? "👍" : undefined, + }); + toast.success("Test notification sent!"); + }} + > + <Icon name="bell" size={16} /> + Send Test Notification + </button> + </div> + </div> + </div> + </div> + {/* Appearance Section */} <div className="settings-section"> <div className="section-header"> diff --git a/front/src/pages/User.tsx b/front/src/pages/User.tsx index d8b66e1..b73cd96 100644 --- a/front/src/pages/User.tsx +++ b/front/src/pages/User.tsx @@ -5,7 +5,7 @@ import Profile from "@/components/profile/Profile"; import useLocalState, { useStore } from "@/state/state"; import Icon from "@/components/Icon"; import toast from "react-hot-toast"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { FC } from "@/types/trill"; import type { UserType } from "@/types/nostrill"; import { isValidPatp } from "urbit-ob"; @@ -45,13 +45,16 @@ function UserFeed({ userString: string; isMe: boolean; }) { - const { api, addProfile } = useLocalState((s) => ({ + const { api, addProfile, addNotification, lastFact } = useLocalState((s) => ({ api: s.api, addProfile: s.addProfile, + addNotification: s.addNotification, + lastFact: s.lastFact, })); // auto updating on SSE doesn't work if we do shallow const { following } = useStore(); const feed = following.get(userString); + const hasFeed = !feed ? false : Object.entries(feed).length > 0; const refetch = () => feed; const isFollowing = following.has(userString); @@ -59,6 +62,32 @@ function UserFeed({ const [isAccessLoading, setIsAccessLoading] = useState(false); const [fc, setFC] = useState<FC>(); + useEffect(() => { + console.log("fact", lastFact); + console.log(isFollowLoading); + if (!isFollowLoading) return; + const follow = lastFact?.fols; + if (!follow) return; + if ("new" in follow) { + if (userString !== follow.new.user) return; + toast.success(`Now following ${userString}`); + setIsFollowLoading(false); + addNotification({ + type: "follow", + from: userString, + message: `You are now following ${userString}`, + }); + } else if ("quit" in follow) { + toast.success(`Unfollowed ${userString}`); + setIsFollowLoading(false); + addNotification({ + type: "unfollow", + from: userString, + message: `You unfollowed ${userString}`, + }); + } + }, [lastFact, userString, isFollowLoading]); + const handleFollow = async () => { if (!api) return; @@ -66,18 +95,16 @@ function UserFeed({ try { if (isFollowing) { await api.unfollow(user); - toast.success(`Unfollowed ${userString}`); } else { await api.follow(user); - toast.success(`Now following ${userString}`); + toast.success(`Follow request sent to ${userString}`); } } catch (error) { toast.error( `Failed to ${isFollowing ? "unfollow" : "follow"} ${userString}`, ); - console.error("Follow error:", error); - } finally { setIsFollowLoading(false); + console.error("Follow error:", error); } }; @@ -89,6 +116,11 @@ function UserFeed({ try { const res = await api.peekFeed(user.urbit); toast.success(`Access request sent to ${user.urbit}`); + addNotification({ + type: "access_request", + from: userString, + message: `Access request sent to ${userString}`, + }); if ("error" in res) toast.error(res.error); else { console.log("peeked", res.ok.feed); @@ -102,6 +134,7 @@ function UserFeed({ setIsAccessLoading(false); } }; + console.log({ user, userString, feed, fc }); return ( <div id="user-page"> @@ -147,7 +180,7 @@ function UserFeed({ </div> )} - {feed ? ( + {feed && hasFeed ? ( <div id="feed-proper"> <Composer /> <PostList data={feed} refetch={refetch} /> diff --git a/front/src/state/state.ts b/front/src/state/state.ts index 715427d..f329145 100644 --- a/front/src/state/state.ts +++ b/front/src/state/state.ts @@ -6,6 +6,7 @@ import { create } from "zustand"; import type { UserProfile } from "@/types/nostrill"; import type { Event } from "@/types/nostr"; import type { FC, Poast } from "@/types/trill"; +import type { Notification } from "@/types/notifications"; import { useShallow } from "zustand/shallow"; // TODO handle airlock connection issues // the SSE pipeline has a "status-update" event FWIW @@ -26,6 +27,16 @@ export type LocalState = { addProfile: (key: string, u: UserProfile) => void; following: Map<string, FC>; followers: string[]; + // Notifications + notifications: Notification[]; + unreadNotifications: number; + addNotification: ( + notification: Omit<Notification, "id" | "timestamp" | "read">, + ) => void; + markNotificationRead: (id: string) => void; + markAllNotificationsRead: () => void; + clearNotifications: () => void; + lastFact: any; }; const creator = create<LocalState>(); @@ -50,6 +61,20 @@ export const useStore = creator((set, get) => ({ pubkey, }); } else if ("fact" in data) { + set({ lastFact: data.fact }); + if ("fols" in data.fact) { + const { following, profiles } = get(); + if ("new" in data.fact.fols) { + const { user, feed, profile } = data.fact.fols.new; + following.set(user, feed); + if (profile) profiles.set(user, profile); + set({ following, profiles }); + } + if ("quit" in data.fact.fols) { + following.delete(data.fact.fols.quit); + set({ following }); + } + } if ("post" in data.fact) { if ("add" in data.fact.post) { const post: Poast = data.fact.post.add.post; @@ -73,6 +98,7 @@ export const useStore = creator((set, get) => ({ profiles.set(key, profile); set({ profiles }); }, + lastFact: null, relays: {}, nostrFeed: [], following: new Map(), @@ -83,6 +109,38 @@ export const useStore = creator((set, get) => ({ // composer data composerData: null, setComposerData: (composerData) => set({ composerData }), + // Notifications + notifications: [], + unreadNotifications: 0, + addNotification: (notification) => { + const newNotification: Notification = { + ...notification, + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + timestamp: new Date(), + read: false, + }; + set((state) => ({ + notifications: [newNotification, ...state.notifications], + unreadNotifications: state.unreadNotifications + 1, + })); + }, + markNotificationRead: (id) => { + set((state) => ({ + notifications: state.notifications.map((n) => + n.id === id ? { ...n, read: true } : n, + ), + unreadNotifications: Math.max(0, state.unreadNotifications - 1), + })); + }, + markAllNotificationsRead: () => { + set((state) => ({ + notifications: state.notifications.map((n) => ({ ...n, read: true })), + unreadNotifications: 0, + })); + }, + clearNotifications: () => { + set({ notifications: [], unreadNotifications: 0 }); + }, })); const useShallowStore = <T extends (state: LocalState) => any>( diff --git a/front/src/styles/NotificationCenter.css b/front/src/styles/NotificationCenter.css new file mode 100644 index 0000000..6991118 --- /dev/null +++ b/front/src/styles/NotificationCenter.css @@ -0,0 +1,263 @@ +/* Notification Badge in Sidebar */ +.notification-item { + position: relative; +} + +.notification-icon-wrapper { + position: relative; + display: inline-block; +} + +.notification-badge { + position: absolute; + top: -4px; + right: -8px; + background: var(--color-error); + color: white; + border-radius: 10px; + padding: 2px 6px; + font-size: 10px; + font-weight: bold; + min-width: 18px; + text-align: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } + 100% { + transform: scale(1); + } +} + +/* Notification Center Modal */ +.notification-center { + max-width: 500px; + width: 100%; + max-height: 600px; + display: flex; + flex-direction: column; +} + +.notification-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + border-bottom: 1px solid var(--color-border); +} + +.notification-header h2 { + margin: 0; + color: var(--color-text); + font-size: 24px; +} + +.notification-actions { + display: flex; + gap: 8px; +} + +.mark-all-read-btn, +.clear-all-btn { + padding: 6px 12px; + font-size: 12px; + border: 1px solid var(--color-border); + background: transparent; + color: var(--color-text-secondary); + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.mark-all-read-btn:hover, +.clear-all-btn:hover { + background: var(--color-surface); + color: var(--color-text); + border-color: var(--color-primary); +} + +/* Notification Filters */ +.notification-filters { + display: flex; + gap: 8px; + padding: 12px 20px; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); +} + +.filter-btn { + padding: 8px 16px; + background: transparent; + border: 1px solid var(--color-border); + border-radius: 20px; + color: var(--color-text-secondary); + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.filter-btn:hover { + background: var(--color-background); + color: var(--color-text); +} + +.filter-btn.active { + background: var(--color-primary); + color: white; + border-color: var(--color-primary); +} + +/* Notification List */ +.notification-list { + flex: 1; + overflow-y: auto; + background: var(--color-background); +} + +.no-notifications { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; +} + +.no-notifications p { + margin: 16px 0 0 0; + color: var(--color-text-muted); + font-size: 16px; +} + +/* Notification Item */ +.notification-item { + display: flex; + align-items: flex-start; + padding: 16px 20px; + border-bottom: 1px solid var(--color-border-light); + cursor: pointer; + transition: background 0.2s; + position: relative; +} + +.notification-item:hover { + background: var(--color-surface); +} + +.notification-item.unread { + background: var(--color-surface); +} + +.notification-icon { + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-background); + border-radius: 50%; + margin-right: 12px; +} + +.notification-content { + flex: 1; + min-width: 0; +} + +.notification-user { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.notification-text { + flex: 1; + min-width: 0; +} + +.notification-text p { + margin: 0; + color: var(--color-text); + font-size: 14px; + line-height: 1.4; +} + +.notification-item.unread .notification-text p { + font-weight: 500; +} + +.notification-time { + display: block; + margin-top: 4px; + color: var(--color-text-muted); + font-size: 12px; +} + +.unread-indicator { + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + width: 8px; + height: 8px; + background: var(--color-primary); + border-radius: 50%; + animation: pulse 2s infinite; +} + +/* Scrollbar Styling */ +.notification-list::-webkit-scrollbar { + width: 8px; +} + +.notification-list::-webkit-scrollbar-track { + background: var(--color-background); +} + +.notification-list::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 4px; +} + +.notification-list::-webkit-scrollbar-thumb:hover { + background: var(--color-text-muted); +} + +/* Mobile Responsive */ +@media (max-width: 600px) { + .notification-center { + max-width: 100%; + max-height: 90vh; + } + + .notification-header { + padding: 16px; + } + + .notification-header h2 { + font-size: 20px; + } + + .notification-actions { + flex-direction: column; + gap: 4px; + } + + .mark-all-read-btn, + .clear-all-btn { + padding: 4px 8px; + font-size: 11px; + } + + .notification-item { + padding: 12px 16px; + } +}
\ No newline at end of file diff --git a/front/src/types/notifications.ts b/front/src/types/notifications.ts new file mode 100644 index 0000000..760702a --- /dev/null +++ b/front/src/types/notifications.ts @@ -0,0 +1,28 @@ +import type { Ship } from "./urbit"; + +export type NotificationType = + | "follow" + | "unfollow" + | "mention" + | "reply" + | "repost" + | "react" + | "access_request" + | "access_granted"; + +export interface Notification { + id: string; + type: NotificationType; + from: Ship | string; // Ship for Urbit users, string for Nostr pubkeys + timestamp: Date; + read: boolean; + // Optional context data + postId?: string; + message?: string; + reaction?: string; +} + +export interface NotificationState { + notifications: Notification[]; + unreadCount: number; +}
\ No newline at end of file |