diff --git a/app/(home-pages)/notifications/NotificationList.tsx b/app/(home-pages)/notifications/NotificationList.tsx index a0ec12ee..30852227 100644 --- a/app/(home-pages)/notifications/NotificationList.tsx +++ b/app/(home-pages)/notifications/NotificationList.tsx @@ -11,6 +11,7 @@ import { QuoteNotification } from "./QuoteNotification"; import { BskyPostEmbedNotification } from "./BskyPostEmbedNotification"; import { MentionNotification } from "./MentionNotification"; import { CommentMentionNotification } from "./CommentMentionNotification"; +import { RecommendNotification } from "./RecommendNotification"; export function NotificationList({ notifications, @@ -58,6 +59,9 @@ export function NotificationList({ if (n.type === "comment_mention") { return ; } + if (n.type === "recommend") { + return ; + } })} diff --git a/app/(home-pages)/notifications/RecommendNotification.tsx b/app/(home-pages)/notifications/RecommendNotification.tsx new file mode 100644 index 00000000..3abb49d3 --- /dev/null +++ b/app/(home-pages)/notifications/RecommendNotification.tsx @@ -0,0 +1,48 @@ +import { ContentLayout, Notification } from "./Notification"; +import { HydratedRecommendNotification } from "src/notifications"; +import { blobRefToSrc } from "src/utils/blobRefToSrc"; +import { AppBskyActorProfile } from "lexicons/api"; +import { Avatar } from "components/Avatar"; +import { AtUri } from "@atproto/api"; +import { RecommendTinyFilled } from "components/Icons/RecommendTiny"; + +export const RecommendNotification = ( + props: HydratedRecommendNotification, +) => { + const profileRecord = props.recommendData?.identities?.bsky_profiles + ?.record as AppBskyActorProfile.Record; + const displayName = + profileRecord?.displayName || + props.recommendData?.identities?.bsky_profiles?.handle || + "Someone"; + const docRecord = props.normalizedDocument; + const pubRecord = props.normalizedPublication; + const avatarSrc = + profileRecord?.avatar?.ref && + blobRefToSrc( + profileRecord.avatar.ref, + props.recommendData?.recommender_did || "", + ); + + if (!docRecord) return null; + + const docUri = new AtUri(props.document.uri); + const rkey = docUri.rkey; + const did = docUri.host; + + const href = pubRecord ? `${pubRecord.url}/${rkey}` : `/p/${did}/${rkey}`; + + return ( + } + actionText={<>{displayName} recommended your post} + content={ + + {null} + + } + /> + ); +}; diff --git a/app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts b/app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts index d0295f26..c219720c 100644 --- a/app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts +++ b/app/lish/[did]/[publication]/[rkey]/Interactions/recommendAction.ts @@ -7,6 +7,11 @@ import { TID } from "@atproto/common"; import { AtUri, Un$Typed } from "@atproto/api"; import { supabaseServerClient } from "supabase/serverClient"; import { Json } from "supabase/database.types"; +import { v7 } from "uuid"; +import { + Notification, + pingIdentityToUpdateNotification, +} from "src/notifications"; type RecommendResult = | { success: true; uri: string } @@ -68,6 +73,22 @@ export async function recommendAction(args: { }); console.log(res); + // Notify the document owner + let documentOwner = new AtUri(args.document).host; + if (documentOwner !== credentialSession.did) { + let notification: Notification = { + id: v7(), + recipient: documentOwner, + data: { + type: "recommend", + document_uri: args.document, + recommend_uri: uri.toString(), + }, + }; + await supabaseServerClient.from("notifications").insert(notification); + await pingIdentityToUpdateNotification(documentOwner); + } + return { success: true, uri: uri.toString(), diff --git a/src/notifications.ts b/src/notifications.ts index f4353d2b..be5ae4bb 100644 --- a/src/notifications.ts +++ b/src/notifications.ts @@ -27,7 +27,8 @@ export type NotificationData = | { type: "mention"; document_uri: string; mention_type: "document"; mentioned_uri: string } | { type: "comment_mention"; comment_uri: string; mention_type: "did" } | { type: "comment_mention"; comment_uri: string; mention_type: "publication"; mentioned_uri: string } - | { type: "comment_mention"; comment_uri: string; mention_type: "document"; mentioned_uri: string }; + | { type: "comment_mention"; comment_uri: string; mention_type: "document"; mentioned_uri: string } + | { type: "recommend"; document_uri: string; recommend_uri: string }; export type HydratedNotification = | HydratedCommentNotification @@ -35,22 +36,24 @@ export type HydratedNotification = | HydratedQuoteNotification | HydratedBskyPostEmbedNotification | HydratedMentionNotification - | HydratedCommentMentionNotification; + | HydratedCommentMentionNotification + | HydratedRecommendNotification; export async function hydrateNotifications( notifications: NotificationRow[], ): Promise> { // Call all hydrators in parallel - const [commentNotifications, subscribeNotifications, quoteNotifications, bskyPostEmbedNotifications, mentionNotifications, commentMentionNotifications] = await Promise.all([ + const [commentNotifications, subscribeNotifications, quoteNotifications, bskyPostEmbedNotifications, mentionNotifications, commentMentionNotifications, recommendNotifications] = await Promise.all([ hydrateCommentNotifications(notifications), hydrateSubscribeNotifications(notifications), hydrateQuoteNotifications(notifications), hydrateBskyPostEmbedNotifications(notifications), hydrateMentionNotifications(notifications), hydrateCommentMentionNotifications(notifications), + hydrateRecommendNotifications(notifications), ]); // Combine all hydrated notifications - const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...bskyPostEmbedNotifications, ...mentionNotifications, ...commentMentionNotifications]; + const allHydrated = [...commentNotifications, ...subscribeNotifications, ...quoteNotifications, ...bskyPostEmbedNotifications, ...mentionNotifications, ...commentMentionNotifications, ...recommendNotifications]; // Sort by created_at to maintain order allHydrated.sort( @@ -519,6 +522,58 @@ async function hydrateCommentMentionNotifications(notifications: NotificationRow .filter((n) => n !== null); } +export type HydratedRecommendNotification = Awaited< + ReturnType +>[0]; + +async function hydrateRecommendNotifications(notifications: NotificationRow[]) { + const recommendNotifications = notifications.filter( + (n): n is NotificationRow & { data: ExtractNotificationType<"recommend"> } => + (n.data as NotificationData)?.type === "recommend", + ); + + if (recommendNotifications.length === 0) { + return []; + } + + // Fetch recommend data from the database + const recommendUris = recommendNotifications.map((n) => n.data.recommend_uri); + const documentUris = recommendNotifications.map((n) => n.data.document_uri); + + const [{ data: recommends }, { data: documents }] = await Promise.all([ + supabaseServerClient + .from("recommends_on_documents") + .select("*, identities(bsky_profiles(*))") + .in("uri", recommendUris), + supabaseServerClient + .from("documents") + .select("*, documents_in_publications(publications(*))") + .in("uri", documentUris), + ]); + + return recommendNotifications + .map((notification) => { + const recommendData = recommends?.find((r) => r.uri === notification.data.recommend_uri); + const document = documents?.find((d) => d.uri === notification.data.document_uri); + if (!recommendData || !document) return null; + return { + id: notification.id, + recipient: notification.recipient, + created_at: notification.created_at, + type: "recommend" as const, + recommend_uri: notification.data.recommend_uri, + document_uri: notification.data.document_uri, + recommendData, + document, + normalizedDocument: normalizeDocumentRecord(document.data, document.uri), + normalizedPublication: normalizePublicationRecord( + document.documents_in_publications[0]?.publications?.record, + ), + }; + }) + .filter((n) => n !== null); +} + export async function pingIdentityToUpdateNotification(did: string) { let channel = supabaseServerClient.channel(`identity.atp_did:${did}`); await channel.send({