summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorpolwex <polwex@sortug.com>2025-09-18 03:48:14 +0700
committerpolwex <polwex@sortug.com>2025-09-18 03:48:14 +0700
commitad7ebd1756956724e0b167d88f924e707401a9aa (patch)
tree5f29ab38e41224245a93a2a00318b835278ac596
parent4b016c908dda2019f3bf89e5a3d2eae535e5fbd2 (diff)
fuck yeah
-rw-r--r--desk/app/nostrill.hoon36
-rw-r--r--desk/lib/json/nostrill.hoon15
-rw-r--r--desk/lib/nostrill/comms.hoon24
-rw-r--r--desk/lib/nostrill/follows.hoon69
-rw-r--r--desk/lib/shim.hoon10
-rw-r--r--desk/sur/nostrill.hoon5
-rw-r--r--front/CLAUDE.md60
-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
-rw-r--r--front/src/logic/api.ts2
-rw-r--r--front/src/pages/Settings.tsx38
-rw-r--r--front/src/pages/User.tsx47
-rw-r--r--front/src/state/state.ts58
-rw-r--r--front/src/styles/NotificationCenter.css263
-rw-r--r--front/src/types/notifications.ts28
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