its for when you want to get like notifications for your reposts

feat!: rewrite notification type, add like record and profile record

ptr.pet cd597809 ff5169de

verified
Changed files
+104 -116
server
webapp
+59 -36
server/main.go
···
"log/slog"
"net/http"
"sync/atomic"
+
"time"
"github.com/bluesky-social/indigo/api/bsky"
"github.com/bluesky-social/indigo/atproto/syntax"
···
const ListenTypeFollows = "follows"
type SubscriberData struct {
-
ForActor syntax.DID
-
Conn *websocket.Conn
-
ListenType string
-
ListenTo Set[syntax.DID]
+
forActor syntax.DID
+
conn *websocket.Conn
+
listenType string
+
listenTo Set[syntax.DID]
}
type ActorData struct {
-
targets *hashmap.Map[string, *SubscriberData]
-
likes map[syntax.RecordKey]bsky.FeedLike
-
follows *hashmap.Map[syntax.RecordKey, bsky.GraphFollow]
-
followsCursor atomic.Pointer[string]
+
targets *hashmap.Map[string, *SubscriberData]
+
likes map[syntax.RecordKey]bsky.FeedLike
+
follows *hashmap.Map[syntax.RecordKey, bsky.GraphFollow]
+
followsCursor atomic.Pointer[string]
+
profile *bsky.ActorDefs_ProfileViewDetailed
+
profileFetchedAt time.Time
+
}
+
+
type NotificationActor struct {
+
DID syntax.DID `json:"did"` // the DID of the actor that (un)liked the post
+
Profile *bsky.ActorDefs_ProfileViewDetailed `json:"profile"` // the detailed profile of the actor that (un)liked the post
}
type NotificationMessage struct {
-
Liked bool `json:"liked"`
-
ByDid syntax.DID `json:"did"`
-
RepostURI syntax.ATURI `json:"repost_uri"`
-
PostURI syntax.ATURI `json:"post_uri"`
+
Liked bool `json:"liked"` // whether the message was liked or unliked
+
Actor NotificationActor `json:"actor"` // information about the actor that (un)liked
+
Record bsky.FeedLike `json:"record"` // the raw like record
+
Time int64 `json:"time"` // when the like event came in
}
type SubscriberMessage struct {
···
func getSubscriberDids() []string {
_dids := make(Set[string], subscribers.Len())
subscribers.Range(func(s string, sd *SubscriberData) bool {
-
_dids[string(sd.ForActor)] = struct{}{}
+
_dids[string(sd.forActor)] = struct{}{}
return true
})
dids := make([]string, 0, len(_dids))
···
ud := getActorData(did)
sd := &SubscriberData{
-
ForActor: did,
-
Conn: conn,
-
ListenType: listenType,
+
forActor: did,
+
conn: conn,
+
listenType: listenType,
}
switch listenType {
···
logger.Error("error fetching follows", "error", err)
return
}
-
sd.ListenTo = make(Set[syntax.DID])
+
sd.listenTo = make(Set[syntax.DID])
// use we have stored
ud.follows.Range(func(rk syntax.RecordKey, f bsky.GraphFollow) bool {
-
sd.ListenTo[syntax.DID(f.Subject)] = struct{}{}
+
sd.listenTo[syntax.DID(f.Subject)] = struct{}{}
return true
})
if len(follows) > 0 {
···
ud.followsCursor.Store((*string)(&follows[len(follows)-1].rkey))
for _, f := range follows {
ud.follows.Insert(f.rkey, f.follow)
-
sd.ListenTo[syntax.DID(f.follow.Subject)] = struct{}{}
+
sd.listenTo[syntax.DID(f.follow.Subject)] = struct{}{}
}
}
logger.Info("fetched follows")
case ListenTypeNone:
-
sd.ListenTo = make(Set[syntax.DID])
+
sd.listenTo = make(Set[syntax.DID])
default:
http.Error(w, "invalid listen type", http.StatusBadRequest)
return
}
subscribers.Set(sid, sd)
-
for listenDid := range sd.ListenTo {
+
for listenDid := range sd.listenTo {
markActorForLikes(sid, sd, listenDid)
}
updateFollowStreamOpts()
// delete subscriber after we are done
defer func() {
-
for listenDid := range sd.ListenTo {
+
for listenDid := range sd.listenTo {
unmarkActorForLikes(sid, listenDid)
}
subscribers.Del(sid)
···
switch msg.Type {
case "update_listen_to":
// only allow this if we arent managing listen to
-
if sd.ListenType != ListenTypeNone {
+
if sd.listenType != ListenTypeNone {
continue
}
···
break
}
// remove all current listens and add the ones the user requested
-
for listenDid := range sd.ListenTo {
+
for listenDid := range sd.listenTo {
unmarkActorForLikes(sid, listenDid)
-
delete(sd.ListenTo, listenDid)
+
delete(sd.listenTo, listenDid)
}
for _, listenDid := range innerMsg.ListenTo {
-
sd.ListenTo[listenDid] = struct{}{}
+
sd.listenTo[listenDid] = struct{}{}
markActorForLikes(sid, sd, listenDid)
}
}
···
return nil
}
+
logger := logger.With("actor", byDid, "type", "like")
+
deleted := event.Commit.Operation == models.CommitOperationDelete
rkey := syntax.RecordKey(event.Commit.RKey)
···
return err
}
ud.targets.Range(func(sid string, sd *SubscriberData) bool {
-
if sd.ForActor != reposterDID {
+
if sd.forActor != reposterDID {
return true
}
+
if ud.profile == nil || time.Now().Sub(ud.profileFetchedAt) > time.Hour*24 {
+
profile, err := fetchProfile(ctx, byDid)
+
if err != nil {
+
logger.Error("cant fetch profile", "error", err)
+
} else {
+
ud.profile = profile
+
ud.profileFetchedAt = time.Now()
+
}
+
}
+
notification := NotificationMessage{
-
Liked: !deleted,
-
ByDid: byDid,
-
RepostURI: repostURI,
-
PostURI: syntax.ATURI(like.Subject.Uri),
+
Liked: !deleted,
+
Actor: NotificationActor{
+
DID: byDid,
+
Profile: ud.profile,
+
},
+
Record: like,
+
Time: event.TimeUS,
}
-
if err := sd.Conn.WriteJSON(notification); err != nil {
-
logger.Error("failed to send notification", "subscriber", sd.ForActor, "error", err)
+
if err := sd.conn.WriteJSON(notification); err != nil {
+
logger.Error("failed to send notification", "error", err)
}
return true
})
···
}
ud.targets.Range(func(sid string, sd *SubscriberData) bool {
// if we arent managing then we dont need to update anything
-
if sd.ListenType != ListenTypeFollows {
+
if sd.listenType != ListenTypeFollows {
return true
}
subjectDid := syntax.DID(r.Subject)
if deleted {
unmarkActorForLikes(sid, subjectDid)
-
delete(sd.ListenTo, subjectDid)
+
delete(sd.listenTo, subjectDid)
} else {
-
sd.ListenTo[subjectDid] = struct{}{}
+
sd.listenTo[subjectDid] = struct{}{}
markActorForLikes(sid, sd, subjectDid)
}
return true
+2 -11
server/xrpc.go
···
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/xrpc"
-
"github.com/bluesky-social/jetstream/pkg/models"
)
func findUserPDS(ctx context.Context, did syntax.DID) (string, error) {
···
return pdsURI, nil
}
-
func fetchRecord[v any](ctx context.Context, xrpcClient *xrpc.Client, val *v, event *models.Event) error {
-
out, err := atproto.RepoGetRecord(ctx, xrpcClient, "", event.Commit.Collection, event.Did, event.Commit.RKey)
-
if err != nil {
-
return err
-
}
-
raw, _ := out.Value.MarshalJSON()
-
if err := json.Unmarshal(raw, val); err != nil {
-
return err
-
}
-
return nil
+
func fetchProfile(ctx context.Context, did syntax.DID) (*bsky.ActorDefs_ProfileViewDetailed, error) {
+
return bsky.ActorGetProfile(ctx, &xrpc.Client{Host: "https://public.api.bsky.app"}, string(did))
}
func fetchRecords[v any](ctx context.Context, xrpcClient *xrpc.Client, cb func(syntax.ATURI, v), cursor *string, collection string, did syntax.DID) error {
+20 -61
webapp/src/ActivityItem.tsx
···
-
import { Did, Handle } from "@atcute/lexicons";
-
import { Client, ok, simpleFetchHandler } from "@atcute/client";
import type {} from "@atcute/bluesky";
import type {} from "@atcute/atproto";
-
import { isDid, parseCanonicalResourceUri } from "@atcute/lexicons/syntax";
+
import { parseCanonicalResourceUri } from "@atcute/lexicons/syntax";
import { Component, createSignal, createEffect } from "solid-js";
-
import { ATProtoActivity } from "./types.js";
-
-
const profileCache = new Map<string, ActorData>();
-
const handler = simpleFetchHandler({
-
service: "https://public.api.bsky.app",
-
});
-
const rpc = new Client({ handler });
+
import { Notification } from "./types.js";
interface ActivityItemProps {
-
data: ATProtoActivity;
-
}
-
-
interface ActorData {
-
did: Did;
-
handle: Handle;
-
displayName?: string;
+
data: Notification;
}
export const ActivityItem: Component<ActivityItemProps> = (props) => {
-
const [actorData, setActorData] = createSignal<ActorData | null>(null);
-
const [error, setError] = createSignal<string | null>(null);
const [postUrl, setPostUrl] = createSignal<string | null>(null);
-
const fetchProfile = async (did: string) => {
-
if (!isDid(did)) {
-
setError("user DID invalid");
-
return;
-
}
-
const resp = ok(
-
await rpc.get("app.bsky.actor.getProfile", {
-
params: { actor: did },
-
}),
-
);
-
const data: ActorData = {
-
did,
-
handle: resp.handle,
-
displayName: resp.displayName,
-
};
-
setActorData(data);
-
profileCache.set(data.did, data);
-
};
+
const profile = props.data.actor.profile;
createEffect(() => {
-
const actorData = profileCache.get(props.data.did);
-
if (actorData) {
-
setActorData(actorData);
-
} else {
-
fetchProfile(props.data.did);
-
}
-
-
const postUri = parseCanonicalResourceUri(props.data.post_uri);
+
const postUri = parseCanonicalResourceUri(
+
props.data.record.subject?.uri ?? "",
+
);
if (postUri.ok) {
setPostUrl(
`https://bsky.app/profile/${postUri.value.repo}/post/${postUri.value.rkey}`,
···
>
<p text-wrap>
<span text-lg>{props.data.liked ? "❤️" : "💔"}</span>{" "}
-
{(actorData() && (
+
{(profile && (
<span font-medium text="sm gray-700">
-
{actorData()!.displayName ?? actorData()!.handle}{" "}
-
{actorData()!.displayName && (
+
{profile!.displayName ?? profile!.handle}{" "}
+
{profile!.displayName && (
<span font-normal text-gray-500>
-
(@{actorData()!.handle})
+
(@{profile!.handle})
</span>
)}
</span>
-
)) ||
-
(error() && (
-
<span italic text="xs red-500">
-
!{error()}!
-
</span>
-
)) || (
-
<span font-medium text="sm gray-700">
-
{props.data.did}
-
</span>
-
)}{" "}
+
)) || (
+
<span font-medium text="sm gray-700">
+
{props.data.actor.did}
+
</span>
+
)}{" "}
<span text-gray-800>{props.data.liked ? "liked" : "unliked"}</span>{" "}
<a
text-blue-800
hover="underline text-blue-400"
-
href={postUrl() ?? props.data.post_uri}
+
href={postUrl() ?? props.data.record.subject?.uri}
>
this post
</a>{" "}
</p>
<div grow />
-
<div text="xs gray-500 end">{new Date().toLocaleTimeString()}</div>
+
<div text="xs gray-500 end">
+
{new Date(props.data.time / 1000).toLocaleTimeString()}
+
</div>
</div>
);
};
+10 -4
webapp/src/App.tsx
···
import type {} from "@atcute/atproto";
import { isDid, isHandle } from "@atcute/lexicons/syntax";
import { XrpcHandleResolver } from "@atcute/identity-resolver";
-
import { ATProtoActivity } from "./types.js";
+
import { Notification } from "./types.js";
import { ActivityItem } from "./ActivityItem.jsx";
const handleResolver = new XrpcHandleResolver({
···
const [actorId, setActorId] = createSignal<string>("");
const [serviceDomain, setWsUrl] = createSignal<string>("likes.gaze.systems");
const [isConnected, setIsConnected] = createSignal<boolean>(false);
-
const [items, setItems] = createSignal<ATProtoActivity[]>([]);
+
const [items, setItems] = createSignal<Notification[]>([]);
const [connectionStatus, setConnectionStatus] = createSignal<
"disconnected" | "connecting..." | "connected" | "error"
>("disconnected");
···
ws.close();
}
-
const url = `wss://${host}/subscribe/${did}`;
+
let proto = "wss";
+
const domain = host.split(":").at(0) ?? "";
+
if (["localhost", "0.0.0.0", "127.0.0.1"].some((v) => v === domain)) {
+
proto = "ws";
+
}
+
+
const url = `${proto}://${host}/subscribe/${did}`;
try {
ws = new WebSocket(url);
···
ws.onmessage = (event: MessageEvent) => {
try {
-
const data: ATProtoActivity = JSON.parse(event.data);
+
const data: Notification = JSON.parse(event.data);
setItems((prev) => [data, ...prev]); // add new items to the top
} catch (error) {
console.error("Error parsing JSON:", error);
+13 -4
webapp/src/types.ts
···
-
export interface ATProtoActivity {
-
did: string;
+
import { AppBskyFeedLike } from "@atcute/bluesky";
+
import { ProfileViewDetailed } from "@atcute/bluesky/types/app/actor/defs";
+
import { Did } from "@atcute/lexicons";
+
+
export interface Notification {
liked: boolean;
-
repost_uri: string;
-
post_uri: string;
+
actor: NotificationActor;
+
record: AppBskyFeedLike.Main;
+
time: number;
+
}
+
+
export interface NotificationActor {
+
did: Did;
+
profile?: ProfileViewDetailed;
}