diff options
author | polwex <polwex@sortug.com> | 2025-09-18 03:48:14 +0700 |
---|---|---|
committer | polwex <polwex@sortug.com> | 2025-09-18 03:48:14 +0700 |
commit | ad7ebd1756956724e0b167d88f924e707401a9aa (patch) | |
tree | 5f29ab38e41224245a93a2a00318b835278ac596 | |
parent | 4b016c908dda2019f3bf89e5a3d2eae535e5fbd2 (diff) |
fuck yeah
-rw-r--r-- | desk/app/nostrill.hoon | 36 | ||||
-rw-r--r-- | desk/lib/json/nostrill.hoon | 15 | ||||
-rw-r--r-- | desk/lib/nostrill/comms.hoon | 24 | ||||
-rw-r--r-- | desk/lib/nostrill/follows.hoon | 69 | ||||
-rw-r--r-- | desk/lib/shim.hoon | 10 | ||||
-rw-r--r-- | desk/sur/nostrill.hoon | 5 | ||||
-rw-r--r-- | front/CLAUDE.md | 60 | ||||
-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 |
18 files changed, 890 insertions, 45 deletions
diff --git a/desk/app/nostrill.hoon b/desk/app/nostrill.hoon index cbd1c2f..611830f 100644 --- a/desk/app/nostrill.hoon +++ b/desk/app/nostrill.hoon @@ -1,7 +1,7 @@ /- sur=nostrill, nsur=nostr /+ lib=nostrill, nlib=nostr, sr=sortug, shim, dbug, muta=nostrill-mutations, jsonlib=json-nostrill, - trill=trill-post, comms=nostrill-comms, followlib=follows + trill=trill-post, comms=nostrill-comms, followlib=nostrill-follows /= web /web/router |% +$ versioned-state $%(state-0:sur) @@ -32,11 +32,11 @@ ++ on-load |~ old-state=vase ^- (quip card:agent:gall agent:gall) - :: =/ old-state !<(versioned-state old-state) - :: ?- -.old-state - :: %0 `this(state old-state) - :: == - `this(state (default-state:lib bowl)) + =/ old-state !<(versioned-state old-state) + ?- -.old-state + %0 `this(state old-state) + == + :: `this(state (default-state:lib bowl)) :: ++ on-poke |~ [=mark =vase] @@ -239,6 +239,11 @@ =/ =fact:ui:sur [%post %add *post-wrapper:sur] =/ card (update-ui:cards fact) :_ this :~(card) + %kick + :_ this =/ subs ~(tap by sup.bowl) + %+ turn subs |= [* p=@p pat=path] + [%give %kick ~[pat] ~] + == :: @@ -279,9 +284,22 @@ ++ on-agent |~ [wire=(pole knot) =sign:agent:gall] ^- (quip card:agent:gall agent:gall) - ~& on-agent=wire - ~& on-agent=sign - `this + ~& on-agent=[wire -.sign] + :: if p.sign is not ~ here that means it's intentional + ?+ wire `this + [%refollow ~] + ?. ?=(%watch-ack -.sign) `this + ?~ p.sign `this + =^ cs state (handle-kick-nack:fols src.bowl) [cs this] + [%follow ~] + ?: ?=(%kick -.sign) + =^ cs state (handle-refollow:fols src.bowl) + [cs this] + ?. ?=(%fact -.sign) `this + =^ cs state (handle-agent-res:fols q.q.cage.sign) + [cs this] + + == :: ++ on-arvo |~ [wire=(pole knot) =sign-arvo] diff --git a/desk/lib/json/nostrill.hoon b/desk/lib/json/nostrill.hoon index 2edf7f4..3c6f21b 100644 --- a/desk/lib/json/nostrill.hoon +++ b/desk/lib/json/nostrill.hoon @@ -49,7 +49,8 @@ %- pairs %+ turn ~(tap by m) |= [key=user:sur f=feed:feed] =/ jkey (user key) ?> ?=(%s -.jkey) - :- +.jkey (feed:en:trill f) + :: TODO proper cursor stuff + :- +.jkey (feed-with-cursor:en:trill f ~ ~) ++ engraph |= m=(map user:sur (set user:sur)) @@ -80,6 +81,18 @@ %nostr (en-nostr-feed +.f) %post (postfact +.f) %enga (enga +.f) + %fols (fols +.f) + == + ++ fols |= ff=fols-fact:ui:sur ^- json + %+ frond -.ff + ?- -.ff + %quit (user +.ff) + %new %: pairs + user+(user user.ff) + feed+(feed-with-cursor:en:trill fc.ff) + :- 'profile' ?~ meta.ff ~ (user-meta:en:nostr u.meta.ff) + ~ + == == ++ tedfact |= pf=post-fact:ui:sur ^- json %+ frond -.pf diff --git a/desk/lib/nostrill/comms.hoon b/desk/lib/nostrill/comms.hoon index 87a63b2..23e442a 100644 --- a/desk/lib/nostrill/comms.hoon +++ b/desk/lib/nostrill/comms.hoon @@ -38,7 +38,8 @@ =/ prof (~(get by profiles.state) [%urbit our.bowl]) (res-fact [%ok %feed fc prof] pat) -++ give-ted |= [id=@ pat=path] + +++ give-ted |= [id=@ pat=path] =/ ted (get:orm:feed feed.state id) ?~ ted (res-fact [%ng 'no such thread'] pat) @@ -70,15 +71,20 @@ =/ =poke:comms [%res res] =/ cage [%noun !>(poke)] [%pass /poke %agent [src.bowl dap.bowl] %poke cage] + ++ res-fact |= [=res:comms pat=path] ^- (list card:agent:gall) + =/ beg ?=([%beg *] pat) =/ paths ~[pat] - =/ =poke:comms [%res res] - ~& > giving-res-fact=res - =/ jon (beg-res:en:jsonlib res) - =/ cage [%json !>(jon)] - :~ - [%give %fact paths cage] - [%give %kick paths ~] - == + ~& > giving-res-fact=pat + ?: beg :: for the thread that goes directly to the frontend + =/ jon (beg-res:en:jsonlib res) + =/ cage [%json !>(jon)] + =/ c1 [%give %fact paths cage] + =/ c2 [%give %kick paths ~] + :~(c1 c2) + :: for the follow flow + =/ cage [%noun !>(res)] + =/ c1 [%give %fact paths cage] + :~(c1) -- diff --git a/desk/lib/nostrill/follows.hoon b/desk/lib/nostrill/follows.hoon index c2eb987..1cf8a66 100644 --- a/desk/lib/nostrill/follows.hoon +++ b/desk/lib/nostrill/follows.hoon @@ -1,12 +1,20 @@ /- sur=nostrill, nsur=nostr, comms=nostrill-comms, feed=trill-feed -/+ js=json-nostr, sr=sortug, nlib=nostr, constants, gatelib=trill-gate, feedlib=trill-feed, jsonlib=json-nostrill +/+ lib=nostrill, js=json-nostr, shim, sr=sortug, nlib=nostr, constants, gatelib=trill-gate, feedlib=trill-feed, jsonlib=json-nostrill |_ [=state:sur =bowl:gall] ++ handle-add |= =user:sur ^- (quip card:agent:gall _state) ?- -.user %urbit =/ c (urbit-watch +.user) :- :~(c) state - %nostr `state + %nostr =/ shimm ~(. shim [state bowl]) + :: TODO now or on receival? + =. following.state (~(put by following.state) user *feed:feed) + =/ graph (~(get by follow-graph.state) [%urbit our.bowl]) + =/ follows ?~ graph (silt ~[user]) (~(put in u.graph) user) + =. follow-graph.state (~(put by follow-graph.state) [%urbit our.bowl] follows) + + =^ cards state (get-user-feed:shimm +.user) + [cards state] == ++ handle-del |= =user:sur ^- (quip card:agent:gall _state) @@ -15,16 +23,61 @@ ?~ graph `state =/ nset (~(del in u.graph) user) =. follow-graph.state (~(put by follow-graph.state) [%urbit our.bowl] nset) - `state -++ handle-follow-ok |= [=user:sur =feed:feed profile=(unit user-meta:nsur)] + :_ state + =/ =fact:ui:sur [%fols %quit user] + =/ c1 (update-ui:cards:lib fact) + ?. ?=(%urbit -.user) :~(c1) + ~& >> leaving=user + =/ c2 (urbit-leave +.user) + :~(c1 c2) + +++ handle-agent-res |= raw=* + ~& "handling-agent-res" + =/ =res:comms ;; res:comms raw + ~& res=-.res + ?- -.res + %ng :: bruh + `state + %ok + ?- -.p.res + %feed (handle-follow-ok [%urbit src.bowl] fc.+.p.res profile.+.p.res) + %thread `state + == + == +++ handle-refollow |= sip=@p + :_ state :_ ~ + :: (urbit-watch sip) + [%pass /refollow %agent [sip dap.bowl] %watch /followre] + +++ handle-follow-ok |= [=user:sur =fc:feed profile=(unit user-meta:nsur)] + ^- (quip card:agent:gall _state) + =. following.state (~(put by following.state) user feed.fc) + =/ graph (~(get by follow-graph.state) [%urbit our.bowl]) + =/ follows ?~ graph (silt ~[user]) (~(put in u.graph) user) + =. follow-graph.state (~(put by follow-graph.state) [%urbit our.bowl] follows) + =. profiles.state ?~ profile profiles.state (~(put by profiles.state) user u.profile) + :_ state + =/ =fact:ui:sur [%fols %new [%urbit src.bowl] fc profile] + =/ c (update-ui:cards:lib fact) :~(c) + + +++ handle-kick-nack |= p=@p ^- (quip card:agent:gall _state) - =. following (~(put by following) user feed) - =. profiles ?~ profile profiles (~(put by profiles) user u.profile) - `state + =. following.state (~(del by following.state) [%urbit p]) + =/ graph (~(get by follow-graph.state) [%urbit our.bowl]) + ?~ graph `state + =/ ngraph (~(del in u.graph) [%urbit p]) + =. follow-graph.state (~(put by follow-graph.state) [%urbit our.bowl] ngraph) + :_ state + =/ =fact:ui:sur [%fols %quit %urbit src.bowl] + =/ c (update-ui:cards:lib fact) :~(c) +++ urbit-leave |= sip=@p ^- card:agent:gall + [%pass /follow %agent [sip dap.bowl] %leave ~] + ++ urbit-watch |= sip=@p ^- card:agent:gall - [%pass /watch %agent [sip dap.bowl] %watch /follow] + [%pass /follow %agent [sip dap.bowl] %watch /follow] :: ++ res-fact |= =res:comms ^- (list card:agent:gall) :: =/ paths ~[/beg/feed] diff --git a/desk/lib/shim.hoon b/desk/lib/shim.hoon index 1b78f0a..d9a5e6e 100644 --- a/desk/lib/shim.hoon +++ b/desk/lib/shim.hoon @@ -42,6 +42,16 @@ =^ req=bulk-req:shim:nsur state (get-req ~[filter]) :- :~((send req)) state +++ get-user-feed + |= pubkey=@ux + ^- (quip card _state) + =/ kinds (silt ~[1]) + :: =/ since (to-unix-secs:jikan:sr last-week) + =/ pubkeys (silt ~[pubkey]) + =/ =filter:nsur [~ `pubkeys `kinds ~ ~ ~ ~] + =^ req=bulk-req:shim:nsur state (get-req ~[filter]) + :- :~((send req)) state + ++ get-profiles |= pubkeys=(set @ux) ^- (quip card _state) diff --git a/desk/sur/nostrill.hoon b/desk/sur/nostrill.hoon index a091dd0..b443ca8 100644 --- a/desk/sur/nostrill.hoon +++ b/desk/sur/nostrill.hoon @@ -73,10 +73,15 @@ $: pub=(unit @ux) $% [%nostr feed=nostr-feed] [%post post-fact] [%enga p=post-wrapper reaction=*] + [%fols fols-fact] == +$ post-fact $% [%add post-wrapper] [%del post-wrapper] == + +$ fols-fact + $% [%new =user =fc:trill meta=(unit user-meta:nostr)] + [%quit =user] + == -- -- diff --git a/front/CLAUDE.md b/front/CLAUDE.md index 64ccf9b..e2c47e1 100644 --- a/front/CLAUDE.md +++ b/front/CLAUDE.md @@ -69,4 +69,62 @@ The project uses `@` alias for `src/` directory (configured in vite.config.ts). 1. **Initialization**: App.tsx → state.init() → api.start() → Urbit connection 2. **State Updates**: Urbit SSE → IO subscriptions → Zustand store updates -3. **User Actions**: Components → IO methods → Urbit pokes/scries → State updates
\ No newline at end of file +3. **User Actions**: Components → IO methods → Urbit pokes/scries → State updates + +## Future Feature Ideas + +### 1. Real-time Notifications System +- Toast notifications for new follows, mentions, access requests +- Bell icon with notification count in sidebar +- Notification center/inbox with activity history +- Sound/desktop notifications +- Mark as read/unread functionality + +### 2. Advanced Post Composer +- Rich text editor with markdown support +- Image/media upload and preview +- Draft saving (auto-save as you type) +- Post scheduling for later +- Character count and formatting preview +- Emoji picker integration +- @mentions with autocomplete + +### 3. Search & Discovery +- Global search for posts, users, hashtags +- Trending topics/hashtags +- User discovery with suggested follows +- Advanced filters (date range, user, content type) +- Search history and saved searches + +### 4. Bookmarking System +- Save posts for later reading +- Organize bookmarks into collections/folders +- Search through saved posts +- Export bookmarks +- Private notes on bookmarks + +### 5. Post Analytics Dashboard +- View engagement metrics on your posts +- Follower growth over time graphs +- Most popular posts +- Activity heatmap +- Engagement rate tracking +- Best posting times analysis + +### 6. Direct Messaging +- Private messaging between users +- Encrypted DMs via Nostr/Urbit protocols +- Message threads and history +- File sharing in DMs +- Read receipts +- Typing indicators + +### 7. Additional Features to Consider +- User groups/communities +- Content moderation tools +- Multi-account support +- Import/export data +- PWA support for mobile +- Keyboard shortcuts +- Custom themes creator +- Language translations
\ No newline at end of file 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 |