From d4e8d9ae7ce8542e6058220493b78fa1c95db36d Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 20 Dec 2024 14:14:47 +0100 Subject: [PATCH 1/7] feat: notification filters --- api/resolvers/notifications.js | 492 +++++++++++---------- components/notifications-filter.js | 105 +++++ components/notifications-filter.module.css | 9 + components/notifications.js | 4 +- lib/constants.js | 1 + svgs/equalizer-line.svg | 1 + 6 files changed, 382 insertions(+), 230 deletions(-) create mode 100644 components/notifications-filter.js create mode 100644 components/notifications-filter.module.css create mode 100644 svgs/equalizer-line.svg diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index a7af37bc5..fde2f0fb2 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -70,297 +70,331 @@ export default { // queries ... we only ever need at most LIMIT+current offset in the child queries to // have enough items to return in the union + const include = new Set(inc ? inc.split(',') : []) + const queries = [] const itemDrivenQueries = [] + // types = types || [] + // const selectedTypes = include.length ? `WHERE type IN (${include.map(type => `'${type}'`).join(', ')})` : '' + // Thread subscriptions - itemDrivenQueries.push( - `SELECT "Item".*, "Item".created_at AS "sortTime", 'Reply' AS type - FROM "ThreadSubscription" - JOIN "Reply" r ON "ThreadSubscription"."itemId" = r."ancestorId" - JOIN "Item" ON r."itemId" = "Item".id - ${whereClause( - '"ThreadSubscription"."userId" = $1', - 'r.created_at >= "ThreadSubscription".created_at', - 'r.created_at < $2', - 'r."userId" <> $1', - ...(meFull.noteAllDescendants ? [] : ['r.level = 1']) - )} - ORDER BY "sortTime" DESC - LIMIT ${LIMIT}` - ) + if (!include.size || include.has('replies')) { + itemDrivenQueries.push( + `SELECT "Item".*, "Item".created_at AS "sortTime", 'Reply' AS type + FROM "ThreadSubscription" + JOIN "Reply" r ON "ThreadSubscription"."itemId" = r."ancestorId" + JOIN "Item" ON r."itemId" = "Item".id + ${whereClause( + '"ThreadSubscription"."userId" = $1', + 'r.created_at >= "ThreadSubscription".created_at', + 'r.created_at < $2', + 'r."userId" <> $1', + ...(meFull.noteAllDescendants ? [] : ['r.level = 1']) + )} + ORDER BY "sortTime" DESC + LIMIT ${LIMIT}` + ) + } // User subscriptions // Only include posts or comments created after the corresponding subscription was enabled, not _all_ from history - itemDrivenQueries.push( - `SELECT "Item".*, "Item".created_at AS "sortTime", 'FollowActivity' AS type - FROM "Item" - JOIN "UserSubscription" ON "Item"."userId" = "UserSubscription"."followeeId" - ${whereClause( - '"Item".created_at < $2', - '"UserSubscription"."followerId" = $1', - `( - ("Item"."parentId" IS NULL AND "UserSubscription"."postsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."postsSubscribedAt") - OR ("Item"."parentId" IS NOT NULL AND "UserSubscription"."commentsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."commentsSubscribedAt") - )` - )} - ORDER BY "sortTime" DESC - LIMIT ${LIMIT}` - ) - - // Territory subscriptions - itemDrivenQueries.push( - `SELECT "Item".*, "Item".created_at AS "sortTime", 'TerritoryPost' AS type - FROM "Item" - JOIN "SubSubscription" ON "Item"."subName" = "SubSubscription"."subName" - ${whereClause( - '"Item".created_at < $2', - '"SubSubscription"."userId" = $1', - '"Item"."userId" <> $1', - '"Item"."parentId" IS NULL', - '"Item".created_at >= "SubSubscription".created_at' - )} - ORDER BY "sortTime" DESC - LIMIT ${LIMIT}` - ) - - // mentions - if (meFull.noteMentions) { + if (!include.size || include.has('followed')) { itemDrivenQueries.push( - `SELECT "Item".*, "Mention".created_at AS "sortTime", 'Mention' AS type - FROM "Mention" - JOIN "Item" ON "Mention"."itemId" = "Item".id + `SELECT "Item".*, "Item".created_at AS "sortTime", 'FollowActivity' AS type + FROM "Item" + JOIN "UserSubscription" ON "Item"."userId" = "UserSubscription"."followeeId" ${whereClause( '"Item".created_at < $2', - '"Mention"."userId" = $1', - '"Item"."userId" <> $1' + '"UserSubscription"."followerId" = $1', + `( + ("Item"."parentId" IS NULL AND "UserSubscription"."postsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."postsSubscribedAt") + OR ("Item"."parentId" IS NOT NULL AND "UserSubscription"."commentsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."commentsSubscribedAt") + )` )} ORDER BY "sortTime" DESC LIMIT ${LIMIT}` ) } - // item mentions - if (meFull.noteItemMentions) { + + // Territory subscriptions + if (!include.size || include.has('territories')) { itemDrivenQueries.push( - `SELECT "Referrer".*, "ItemMention".created_at AS "sortTime", 'ItemMention' AS type - FROM "ItemMention" - JOIN "Item" "Referee" ON "ItemMention"."refereeId" = "Referee".id - JOIN "Item" "Referrer" ON "ItemMention"."referrerId" = "Referrer".id + `SELECT "Item".*, "Item".created_at AS "sortTime", 'TerritoryPost' AS type + FROM "Item" + JOIN "SubSubscription" ON "Item"."subName" = "SubSubscription"."subName" ${whereClause( - '"ItemMention".created_at < $2', - '"Referrer"."userId" <> $1', - '"Referee"."userId" = $1' + '"Item".created_at < $2', + '"SubSubscription"."userId" = $1', + '"Item"."userId" <> $1', + '"Item"."parentId" IS NULL', + '"Item".created_at >= "SubSubscription".created_at' )} ORDER BY "sortTime" DESC LIMIT ${LIMIT}` ) } - // Inner union to de-dupe item-driven notifications - queries.push( - // Only record per item ID - `( - SELECT DISTINCT ON (id) "Item".id::TEXT, "Item"."sortTime", NULL::BIGINT AS "earnedSats", "Item".type - FROM ( - ${itemDrivenQueries.map(q => `(${q})`).join(' UNION ALL ')} - ) as "Item" - ${whereClause( - '"Item".created_at < $2', - await filterClause(me, models), - muteClause(me), - activeOrMine(me))} - ORDER BY id ASC, CASE - WHEN type = 'Mention' THEN 1 - WHEN type = 'Reply' THEN 2 - WHEN type = 'FollowActivity' THEN 3 - WHEN type = 'TerritoryPost' THEN 4 - WHEN type = 'ItemMention' THEN 5 - END ASC - )` - ) - // territory transfers - queries.push( - `(SELECT "TerritoryTransfer".id::text, "TerritoryTransfer"."created_at" AS "sortTime", NULL as "earnedSats", - 'TerritoryTransfer' AS type - FROM "TerritoryTransfer" - WHERE "TerritoryTransfer"."newUserId" = $1 - AND "TerritoryTransfer"."created_at" <= $2 - ORDER BY "sortTime" DESC - LIMIT ${LIMIT})` - ) + if (!include.size || include.has('replies')) { + // mentions + if (meFull.noteMentions) { + itemDrivenQueries.push( + `SELECT "Item".*, "Mention".created_at AS "sortTime", 'Mention' AS type + FROM "Mention" + JOIN "Item" ON "Mention"."itemId" = "Item".id + ${whereClause( + '"Item".created_at < $2', + '"Mention"."userId" = $1', + '"Item"."userId" <> $1' + )} + ORDER BY "sortTime" DESC + LIMIT ${LIMIT}` + ) + } - if (meFull.noteItemSats) { + // item mentions + if (meFull.noteItemMentions) { + itemDrivenQueries.push( + `SELECT "Referrer".*, "ItemMention".created_at AS "sortTime", 'ItemMention' AS type + FROM "ItemMention" + JOIN "Item" "Referee" ON "ItemMention"."refereeId" = "Referee".id + JOIN "Item" "Referrer" ON "ItemMention"."referrerId" = "Referrer".id + ${whereClause( + '"ItemMention".created_at < $2', + '"Referrer"."userId" <> $1', + '"Referee"."userId" = $1' + )} + ORDER BY "sortTime" DESC + LIMIT ${LIMIT}` + ) + } + } + // Inner union to de-dupe item-driven notifications + if (!include.size || include.has('replies') || include.has('territories') || include.has('followed')) { queries.push( - `(SELECT "Item".id::TEXT, "Item"."lastZapAt" AS "sortTime", - "Item".msats/1000 as "earnedSats", 'Votification' AS type - FROM "Item" - WHERE "Item"."userId" = $1 - AND "Item"."lastZapAt" < $2 - ORDER BY "sortTime" DESC - LIMIT ${LIMIT})` + // Only record per item ID + `( + SELECT DISTINCT ON (id) "Item".id::TEXT, "Item"."sortTime", NULL::BIGINT AS "earnedSats", "Item".type + FROM ( + ${itemDrivenQueries.map(q => `(${q})`).join(' UNION ALL ')} + ) as "Item" + ${whereClause( + '"Item".created_at < $2', + await filterClause(me, models), + muteClause(me), + activeOrMine(me))} + ORDER BY id ASC, CASE + WHEN type = 'Mention' THEN 1 + WHEN type = 'Reply' THEN 2 + WHEN type = 'FollowActivity' THEN 3 + WHEN type = 'TerritoryPost' THEN 4 + WHEN type = 'ItemMention' THEN 5 + END ASC + )` ) } - if (meFull.noteForwardedSats) { + // territory transfers + if (!include.size || include.has('territories')) { queries.push( - `(SELECT "Item".id::TEXT, "Item"."lastZapAt" AS "sortTime", - ("Item".msats / 1000 * "ItemForward".pct / 100) as "earnedSats", 'ForwardedVotification' AS type - FROM "Item" - JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "ItemForward"."userId" = $1 - WHERE "Item"."userId" <> $1 - AND "Item"."lastZapAt" < $2 + `(SELECT "TerritoryTransfer".id::text, "TerritoryTransfer"."created_at" AS "sortTime", NULL as "earnedSats", + 'TerritoryTransfer' AS type + FROM "TerritoryTransfer" + WHERE "TerritoryTransfer"."newUserId" = $1 + AND "TerritoryTransfer"."created_at" <= $2 ORDER BY "sortTime" DESC LIMIT ${LIMIT})` ) } - if (meFull.noteDeposits) { - queries.push( - `(SELECT "Invoice".id::text, "Invoice"."confirmedAt" AS "sortTime", - FLOOR("Invoice"."msatsReceived" / 1000) as "earnedSats", - 'InvoicePaid' AS type - FROM "Invoice" - WHERE "Invoice"."userId" = $1 - AND "Invoice"."confirmedAt" IS NOT NULL - AND "Invoice"."created_at" < $2 - AND ( - ("Invoice"."isHeld" IS NULL AND "Invoice"."actionType" IS NULL) - OR ( - "Invoice"."actionType" = 'RECEIVE' - AND "Invoice"."actionState" = 'PAID' + if (!include.size || include.has('stacking')) { + if (meFull.noteItemSats) { + queries.push( + `(SELECT "Item".id::TEXT, "Item"."lastZapAt" AS "sortTime", + "Item".msats/1000.0 as "earnedSats", 'Votification' AS type + FROM "Item" + WHERE "Item"."userId" = $1 + AND "Item"."lastZapAt" < $2 + ORDER BY "sortTime" DESC + LIMIT ${LIMIT})` + ) + } + + if (meFull.noteForwardedSats) { + queries.push( + `(SELECT "Item".id::TEXT, "Item"."lastZapAt" AS "sortTime", + ("Item".msats / 1000 * "ItemForward".pct / 100) as "earnedSats", 'ForwardedVotification' AS type + FROM "Item" + JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "ItemForward"."userId" = $1 + WHERE "Item"."userId" <> $1 + AND "Item"."lastZapAt" < $2 + ORDER BY "sortTime" DESC + LIMIT ${LIMIT})` + ) + } + } + + if (!include.size || include.has('payments')) { + if (meFull.noteDeposits) { + queries.push( + `(SELECT "Invoice".id::text, "Invoice"."confirmedAt" AS "sortTime", + FLOOR("Invoice"."msatsReceived" / 1000) as "earnedSats", + 'InvoicePaid' AS type + FROM "Invoice" + WHERE "Invoice"."userId" = $1 + AND "Invoice"."confirmedAt" IS NOT NULL + AND "Invoice"."created_at" < $2 + AND ( + ("Invoice"."isHeld" IS NULL AND "Invoice"."actionType" IS NULL) + OR ( + "Invoice"."actionType" = 'RECEIVE' + AND "Invoice"."actionState" = 'PAID' + ) ) - ) - ORDER BY "sortTime" DESC - LIMIT ${LIMIT})` - ) + ORDER BY "sortTime" DESC + LIMIT ${LIMIT})` + ) + } + + if (meFull.noteWithdrawals) { + queries.push( + `(SELECT "Withdrawl".id::text, MAX(COALESCE("Invoice"."confirmedAt", "Withdrawl".created_at)) AS "sortTime", + FLOOR(MAX("Withdrawl"."msatsPaid" / 1000)) as "earnedSats", + 'WithdrawlPaid' AS type + FROM "Withdrawl" + LEFT JOIN "InvoiceForward" ON "InvoiceForward"."withdrawlId" = "Withdrawl".id + LEFT JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id + WHERE "Withdrawl"."userId" = $1 + AND "Withdrawl".status = 'CONFIRMED' + AND "Withdrawl".created_at < $2 + AND ("InvoiceForward"."id" IS NULL OR "Invoice"."actionType" = 'ZAP') + GROUP BY "Withdrawl".id + ORDER BY "sortTime" DESC + LIMIT ${LIMIT})` + ) + } } - if (meFull.noteWithdrawals) { - queries.push( - `(SELECT "Withdrawl".id::text, MAX(COALESCE("Invoice"."confirmedAt", "Withdrawl".created_at)) AS "sortTime", - FLOOR(MAX("Withdrawl"."msatsPaid" / 1000)) as "earnedSats", - 'WithdrawlPaid' AS type - FROM "Withdrawl" - LEFT JOIN "InvoiceForward" ON "InvoiceForward"."withdrawlId" = "Withdrawl".id - LEFT JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id - WHERE "Withdrawl"."userId" = $1 - AND "Withdrawl".status = 'CONFIRMED' - AND "Withdrawl".created_at < $2 - AND ("InvoiceForward"."id" IS NULL OR "Invoice"."actionType" = 'ZAP') - GROUP BY "Withdrawl".id + if (!include.size || include.has('referral')) { + if (meFull.noteInvites) { + queries.push( + `(SELECT "Invite".id, MAX(users.created_at) AS "sortTime", NULL as "earnedSats", + 'Invitification' AS type + FROM users JOIN "Invite" on users."inviteId" = "Invite".id + WHERE "Invite"."userId" = $1 + AND users.created_at < $2 + GROUP BY "Invite".id + ORDER BY "sortTime" DESC + LIMIT ${LIMIT})` + ) + queries.push( + `(SELECT users.id::text, users.created_at AS "sortTime", NULL as "earnedSats", + 'Referral' AS type + FROM users + WHERE "users"."referrerId" = $1 + AND "inviteId" IS NULL + AND users.created_at < $2 + ORDER BY "sortTime" DESC + LIMIT ${LIMIT})` + ) + } + } + + if (!include.size || include.has('earnings')) { + if (meFull.noteEarning) { + queries.push( + `(SELECT min(id)::text AS id, created_at AS "sortTime", FLOOR(sum(msats) / 1000) as "earnedSats", + 'Earn' AS type + FROM "Earn" + WHERE "userId" = $1 + AND created_at < $2 + AND (type IS NULL OR type NOT IN ('FOREVER_REFERRAL', 'ONE_DAY_REFERRAL')) + GROUP BY "userId", created_at ORDER BY "sortTime" DESC LIMIT ${LIMIT})` - ) + ) + queries.push( + `(SELECT min(id)::text AS id, created_at AS "sortTime", FLOOR(sum(msats) / 1000) as "earnedSats", + 'Revenue' AS type + FROM "SubAct" + WHERE "userId" = $1 + AND type = 'REVENUE' + AND created_at < $2 + GROUP BY "userId", "subName", created_at + ORDER BY "sortTime" DESC + LIMIT ${LIMIT})` + ) + queries.push( + `(SELECT min(id)::text AS id, created_at AS "sortTime", FLOOR(sum(msats) / 1000) as "earnedSats", + 'ReferralReward' AS type + FROM "Earn" + WHERE "userId" = $1 + AND created_at < $2 + AND type IN ('FOREVER_REFERRAL', 'ONE_DAY_REFERRAL') + GROUP BY "userId", created_at + ORDER BY "sortTime" DESC + LIMIT ${LIMIT})` + ) + } } - if (meFull.noteInvites) { - queries.push( - `(SELECT "Invite".id, MAX(users.created_at) AS "sortTime", NULL as "earnedSats", - 'Invitification' AS type - FROM users JOIN "Invite" on users."inviteId" = "Invite".id - WHERE "Invite"."userId" = $1 - AND users.created_at < $2 - GROUP BY "Invite".id + if (!include.size || include.has('streak')) { + if (meFull.noteCowboyHat) { + queries.push( + `(SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'Streak' AS type + FROM "Streak" + WHERE "userId" = $1 + AND updated_at < $2 ORDER BY "sortTime" DESC LIMIT ${LIMIT})` - ) + ) + } + } + + if (!include.size || include.has('territories')) { queries.push( - `(SELECT users.id::text, users.created_at AS "sortTime", NULL as "earnedSats", - 'Referral' AS type - FROM users - WHERE "users"."referrerId" = $1 - AND "inviteId" IS NULL - AND users.created_at < $2 + `(SELECT "Sub".name::text AS id, "Sub"."statusUpdatedAt" AS "sortTime", NULL as "earnedSats", + 'SubStatus' AS type + FROM "Sub" + WHERE "Sub"."userId" = $1 + AND "status" <> 'ACTIVE' + AND "statusUpdatedAt" < $2 ORDER BY "sortTime" DESC LIMIT ${LIMIT})` ) } - if (meFull.noteEarning) { - queries.push( - `(SELECT min(id)::text, created_at AS "sortTime", FLOOR(sum(msats) / 1000) as "earnedSats", - 'Earn' AS type - FROM "Earn" - WHERE "userId" = $1 - AND created_at < $2 - AND (type IS NULL OR type NOT IN ('FOREVER_REFERRAL', 'ONE_DAY_REFERRAL')) - GROUP BY "userId", created_at - ORDER BY "sortTime" DESC - LIMIT ${LIMIT})` - ) + if (!include.size || include.has('reminders')) { queries.push( - `(SELECT min(id)::text, created_at AS "sortTime", FLOOR(sum(msats) / 1000) as "earnedSats", - 'Revenue' AS type - FROM "SubAct" - WHERE "userId" = $1 - AND type = 'REVENUE' - AND created_at < $2 - GROUP BY "userId", "subName", created_at - ORDER BY "sortTime" DESC - LIMIT ${LIMIT})` - ) - queries.push( - `(SELECT min(id)::text, created_at AS "sortTime", FLOOR(sum(msats) / 1000) as "earnedSats", - 'ReferralReward' AS type - FROM "Earn" - WHERE "userId" = $1 - AND created_at < $2 - AND type IN ('FOREVER_REFERRAL', 'ONE_DAY_REFERRAL') - GROUP BY "userId", created_at + `(SELECT "Reminder".id::text, "Reminder"."remindAt" AS "sortTime", NULL as "earnedSats", 'Reminder' AS type + FROM "Reminder" + WHERE "Reminder"."userId" = $1 + AND "Reminder"."remindAt" < $2 ORDER BY "sortTime" DESC LIMIT ${LIMIT})` ) } - if (meFull.noteCowboyHat) { + if (!include.size || include.has('payments')) { queries.push( - `(SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'Streak' AS type - FROM "Streak" - WHERE "userId" = $1 - AND updated_at < $2 + `(SELECT "Invoice".id::text, "Invoice"."updated_at" AS "sortTime", NULL as "earnedSats", 'Invoicification' AS type + FROM "Invoice" + WHERE "Invoice"."userId" = $1 + AND "Invoice"."updated_at" < $2 + AND "Invoice"."actionState" = 'FAILED' + AND ( + "Invoice"."actionType" = 'ITEM_CREATE' OR + "Invoice"."actionType" = 'ZAP' OR + "Invoice"."actionType" = 'DOWN_ZAP' OR + "Invoice"."actionType" = 'POLL_VOTE' OR + "Invoice"."actionType" = 'BOOST' + ) ORDER BY "sortTime" DESC LIMIT ${LIMIT})` ) } - queries.push( - `(SELECT "Sub".name::text, "Sub"."statusUpdatedAt" AS "sortTime", NULL as "earnedSats", - 'SubStatus' AS type - FROM "Sub" - WHERE "Sub"."userId" = $1 - AND "status" <> 'ACTIVE' - AND "statusUpdatedAt" < $2 - ORDER BY "sortTime" DESC - LIMIT ${LIMIT})` - ) - - queries.push( - `(SELECT "Reminder".id::text, "Reminder"."remindAt" AS "sortTime", NULL as "earnedSats", 'Reminder' AS type - FROM "Reminder" - WHERE "Reminder"."userId" = $1 - AND "Reminder"."remindAt" < $2 - ORDER BY "sortTime" DESC - LIMIT ${LIMIT})` - ) - - queries.push( - `(SELECT "Invoice".id::text, "Invoice"."updated_at" AS "sortTime", NULL as "earnedSats", 'Invoicification' AS type - FROM "Invoice" - WHERE "Invoice"."userId" = $1 - AND "Invoice"."updated_at" < $2 - AND "Invoice"."actionState" = 'FAILED' - AND ( - "Invoice"."actionType" = 'ITEM_CREATE' OR - "Invoice"."actionType" = 'ZAP' OR - "Invoice"."actionType" = 'DOWN_ZAP' OR - "Invoice"."actionType" = 'POLL_VOTE' OR - "Invoice"."actionType" = 'BOOST' - ) - ORDER BY "sortTime" DESC - LIMIT ${LIMIT})` - ) - const notifications = await models.$queryRawUnsafe( `SELECT id, "sortTime", "earnedSats", type, "sortTime" AS "minSortTime" diff --git a/components/notifications-filter.js b/components/notifications-filter.js new file mode 100644 index 000000000..6a9cc1669 --- /dev/null +++ b/components/notifications-filter.js @@ -0,0 +1,105 @@ +import { useState, useEffect, useCallback, useMemo } from 'react' +import { useShowModal } from './modal' +import { useRouter } from 'next/router' +import { NOTIFICATION_CATEGORIES } from '../lib/constants' +import { Checkbox, Form, SubmitButton } from './form' +import FilterIcon from '@/svgs/equalizer-line.svg' +import styles from './notifications-filter.module.css' + +export function NotificationsFilter ({ onClose }) { + const router = useRouter() + + const initialFilters = useMemo(() => { + const filters = new Set(router.query.inc?.split(',') || []) + filters.delete('') + return filters + }, [router.query.inc]) + + const [filters, setFilters] = useState(initialFilters) + + useEffect(() => { + setFilters(initialFilters) + }, [router.query.inc, initialFilters]) + + const handleFilters = useCallback((filter, add) => { + setFilters(prev => { + const newFilters = new Set(prev) + if (add) { + newFilters.add(filter) + } else { + newFilters.delete(filter) + } + newFilters.delete('') + return newFilters + }) + }, []) + + const filterRoutePush = useCallback(() => { + const incstr = [...filters].join(',') + router.push(`/notifications?inc=${incstr}`) + }, [filters, router]) + + return ( + <> +
+

notifications filter

+

+ filtering by: + {filters.size ? ` ${[...filters].join(', ')}` : ' all'} +

+
{ + filterRoutePush() + onClose?.() + }} + > +
+ {NOTIFICATION_CATEGORIES.map((category) => ( + handleFilters(category, c)} + /> + ))} +
+
+ {filters.size ? setFilters(new Set())}>reset : null} + apply filters +
+
+
+ + ) +} + +export default function NotificationsHeader () { + const showModal = useShowModal() + const router = useRouter() + const [active, setActive] = useState([]) + + useEffect(() => { + const initialFilters = new Set(router.query.inc?.split(',') || []) + initialFilters.delete('') + setActive(initialFilters) + }, [router.query.inc]) + + return ( +
+

notifications

+ { + showModal((onClose) => ( + + )) + }} + /> +
+ ) +} diff --git a/components/notifications-filter.module.css b/components/notifications-filter.module.css new file mode 100644 index 000000000..13c461262 --- /dev/null +++ b/components/notifications-filter.module.css @@ -0,0 +1,9 @@ +.filterIcon { + cursor: pointer; +} + +.filterIconActive { + cursor: pointer; + fill: var(--theme-brandColor); + filter: drop-shadow(0 0 2px var(--bs-primary)); +} \ No newline at end of file diff --git a/components/notifications.js b/components/notifications.js index a4937c3bf..46347be5c 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -43,6 +43,7 @@ import { useToast } from './toast' import classNames from 'classnames' import HolsterIcon from '@/svgs/holster.svg' import SaddleIcon from '@/svgs/saddle.svg' +import NotificationsHeader from './notifications-filter' function Notification ({ n, fresh }) { const type = n.__typename @@ -721,8 +722,8 @@ export function NotificationAlert () { const nid = n => n.__typename + n.id + n.sortTime export default function Notifications ({ ssrData }) { - const { data, fetchMore } = useQuery(NOTIFICATIONS) const router = useRouter() + const { data, fetchMore } = useQuery(NOTIFICATIONS, { variables: { inc: router.query.inc } }) const dat = useData(data, ssrData) const { notifications, lastChecked, cursor } = useMemo(() => { @@ -753,6 +754,7 @@ export default function Notifications ({ ssrData }) { return ( <> + {notifications.map(n => \ No newline at end of file From dc187ef743762a4600e1969a3e40a7a25934a1b0 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 21 Dec 2024 11:35:27 +0100 Subject: [PATCH 2/7] fix: double refresh on filters reset, notifications with NULL handled as BIGINT; cleanup: better names, removed redundant code --- api/resolvers/notifications.js | 12 ++-- components/notifications-filter.js | 95 ++++++++++++++++-------------- 2 files changed, 56 insertions(+), 51 deletions(-) diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index fde2f0fb2..59045fc8e 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -198,7 +198,7 @@ export default { // territory transfers if (!include.size || include.has('territories')) { queries.push( - `(SELECT "TerritoryTransfer".id::text, "TerritoryTransfer"."created_at" AS "sortTime", NULL as "earnedSats", + `(SELECT "TerritoryTransfer".id::text, "TerritoryTransfer"."created_at" AS "sortTime", NULL::BIGINT as "earnedSats", 'TerritoryTransfer' AS type FROM "TerritoryTransfer" WHERE "TerritoryTransfer"."newUserId" = $1 @@ -279,7 +279,7 @@ export default { if (!include.size || include.has('referral')) { if (meFull.noteInvites) { queries.push( - `(SELECT "Invite".id, MAX(users.created_at) AS "sortTime", NULL as "earnedSats", + `(SELECT "Invite".id, MAX(users.created_at) AS "sortTime", NULL::BIGINT as "earnedSats", 'Invitification' AS type FROM users JOIN "Invite" on users."inviteId" = "Invite".id WHERE "Invite"."userId" = $1 @@ -289,7 +289,7 @@ export default { LIMIT ${LIMIT})` ) queries.push( - `(SELECT users.id::text, users.created_at AS "sortTime", NULL as "earnedSats", + `(SELECT users.id::text, users.created_at AS "sortTime", NULL::BIGINT as "earnedSats", 'Referral' AS type FROM users WHERE "users"."referrerId" = $1 @@ -354,7 +354,7 @@ export default { if (!include.size || include.has('territories')) { queries.push( - `(SELECT "Sub".name::text AS id, "Sub"."statusUpdatedAt" AS "sortTime", NULL as "earnedSats", + `(SELECT "Sub".name::text AS id, "Sub"."statusUpdatedAt" AS "sortTime", NULL::BIGINT as "earnedSats", 'SubStatus' AS type FROM "Sub" WHERE "Sub"."userId" = $1 @@ -367,7 +367,7 @@ export default { if (!include.size || include.has('reminders')) { queries.push( - `(SELECT "Reminder".id::text, "Reminder"."remindAt" AS "sortTime", NULL as "earnedSats", 'Reminder' AS type + `(SELECT "Reminder".id::text, "Reminder"."remindAt" AS "sortTime", NULL::BIGINT as "earnedSats", 'Reminder' AS type FROM "Reminder" WHERE "Reminder"."userId" = $1 AND "Reminder"."remindAt" < $2 @@ -378,7 +378,7 @@ export default { if (!include.size || include.has('payments')) { queries.push( - `(SELECT "Invoice".id::text, "Invoice"."updated_at" AS "sortTime", NULL as "earnedSats", 'Invoicification' AS type + `(SELECT "Invoice".id::text, "Invoice"."updated_at" AS "sortTime", NULL::BIGINT as "earnedSats", 'Invoicification' AS type FROM "Invoice" WHERE "Invoice"."userId" = $1 AND "Invoice"."updated_at" < $2 diff --git a/components/notifications-filter.js b/components/notifications-filter.js index 6a9cc1669..4727013fe 100644 --- a/components/notifications-filter.js +++ b/components/notifications-filter.js @@ -9,17 +9,17 @@ import styles from './notifications-filter.module.css' export function NotificationsFilter ({ onClose }) { const router = useRouter() - const initialFilters = useMemo(() => { + const appliedFilters = useMemo(() => { const filters = new Set(router.query.inc?.split(',') || []) filters.delete('') return filters }, [router.query.inc]) - const [filters, setFilters] = useState(initialFilters) + const [filters, setFilters] = useState(appliedFilters) useEffect(() => { - setFilters(initialFilters) - }, [router.query.inc, initialFilters]) + setFilters(appliedFilters) + }, [appliedFilters]) const handleFilters = useCallback((filter, add) => { setFilters(prev => { @@ -29,71 +29,76 @@ export function NotificationsFilter ({ onClose }) { } else { newFilters.delete(filter) } - newFilters.delete('') return newFilters }) }, []) const filterRoutePush = useCallback(() => { const incstr = [...filters].join(',') - router.push(`/notifications?inc=${incstr}`) + router.replace( // replace is necessary as lastChecked needs to stay to avoid re-refreshes + { + pathname: '/notifications', + query: { + ...router.query, + inc: incstr || undefined + } + }, + `/notifications${incstr ? `?inc=${incstr}` : ''}`, // inc can stay visible in the URL + { shallow: true } + ) }, [filters, router]) return ( - <> -
-

notifications filter

-

- filtering by: - {filters.size ? ` ${[...filters].join(', ')}` : ' all'} -

-
{ - filterRoutePush() - onClose?.() - }} - > -
- {NOTIFICATION_CATEGORIES.map((category) => ( - handleFilters(category, c)} - /> - ))} -
-
- {filters.size ? setFilters(new Set())}>reset : null} - apply filters -
-
-
- +
+

notifications filter

+

+ filtering by: + {filters.size ? ` ${[...filters].join(', ')}` : ' all'} +

+
{ + filterRoutePush() + onClose?.() + }} + > +
+ {NOTIFICATION_CATEGORIES.map((category) => ( + handleFilters(category, c)} + /> + ))} +
+
+ {filters.size ? setFilters(new Set())}>reset : null} + apply filters +
+
+
) } export default function NotificationsHeader () { const showModal = useShowModal() const router = useRouter() - const [active, setActive] = useState([]) + const [active, setActive] = useState(router.query.inc?.length) useEffect(() => { - const initialFilters = new Set(router.query.inc?.split(',') || []) - initialFilters.delete('') - setActive(initialFilters) + setActive(router.query.inc?.length) }, [router.query.inc]) return (
-

notifications

+

notifications

{ showModal((onClose) => ( From cfa1561bf959bb08e1ff3e5e6b8435136c8ca9e2 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sat, 21 Dec 2024 12:51:41 +0100 Subject: [PATCH 3/7] cleanup: remove redundant useEffect and checks; enhance: hover on FilterIcon hints at clickability --- components/notifications-filter.js | 32 +++++++--------------- components/notifications-filter.module.css | 5 ++++ 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/components/notifications-filter.js b/components/notifications-filter.js index 4727013fe..20570ae04 100644 --- a/components/notifications-filter.js +++ b/components/notifications-filter.js @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from 'react' +import { useState, useCallback, useMemo } from 'react' import { useShowModal } from './modal' import { useRouter } from 'next/router' import { NOTIFICATION_CATEGORIES } from '../lib/constants' @@ -10,25 +10,17 @@ export function NotificationsFilter ({ onClose }) { const router = useRouter() const appliedFilters = useMemo(() => { - const filters = new Set(router.query.inc?.split(',') || []) - filters.delete('') + const filters = new Set(router.query.inc?.split(',') || []) // get filters from URL + filters.delete('') // avoid empty category return filters }, [router.query.inc]) const [filters, setFilters] = useState(appliedFilters) - useEffect(() => { - setFilters(appliedFilters) - }, [appliedFilters]) - const handleFilters = useCallback((filter, add) => { setFilters(prev => { const newFilters = new Set(prev) - if (add) { - newFilters.add(filter) - } else { - newFilters.delete(filter) - } + add ? newFilters.add(filter) : newFilters.delete(filter) return newFilters }) }, []) @@ -56,7 +48,7 @@ export function NotificationsFilter ({ onClose }) { {filters.size ? ` ${[...filters].join(', ')}` : ' all'}

{ filterRoutePush() onClose?.() @@ -69,14 +61,14 @@ export function NotificationsFilter ({ onClose }) { label={category} name={category} inline - checked={filters?.has(category)} + checked={filters.has(category)} handleChange={(c) => handleFilters(category, c)} /> ))}
- {filters.size ? setFilters(new Set())}>reset : null} - apply filters + {filters.size ? setFilters(new Set())}>reset : null} + apply filters
@@ -86,11 +78,7 @@ export function NotificationsFilter ({ onClose }) { export default function NotificationsHeader () { const showModal = useShowModal() const router = useRouter() - const [active, setActive] = useState(router.query.inc?.length) - - useEffect(() => { - setActive(router.query.inc?.length) - }, [router.query.inc]) + const hasActiveFilters = router.query.inc?.length return (
@@ -98,7 +86,7 @@ export default function NotificationsHeader () { { showModal((onClose) => ( diff --git a/components/notifications-filter.module.css b/components/notifications-filter.module.css index 13c461262..456ced399 100644 --- a/components/notifications-filter.module.css +++ b/components/notifications-filter.module.css @@ -1,5 +1,10 @@ .filterIcon { cursor: pointer; + fill: var(--theme-grey); +} +.filterIcon:hover { + fill: var(--theme-brandColor); + filter: drop-shadow(0 0 2px var(--bs-primary)); } .filterIconActive { From 0c1b64b89a51d6e17032fd5486a63ec1a4065ed9 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 8 Jan 2025 09:59:24 +0100 Subject: [PATCH 4/7] resolve merge conflicts --- api/resolvers/notifications.js | 36 +++++++++++++++++----------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 59045fc8e..9ab5c5980 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -256,24 +256,6 @@ export default { LIMIT ${LIMIT})` ) } - - if (meFull.noteWithdrawals) { - queries.push( - `(SELECT "Withdrawl".id::text, MAX(COALESCE("Invoice"."confirmedAt", "Withdrawl".created_at)) AS "sortTime", - FLOOR(MAX("Withdrawl"."msatsPaid" / 1000)) as "earnedSats", - 'WithdrawlPaid' AS type - FROM "Withdrawl" - LEFT JOIN "InvoiceForward" ON "InvoiceForward"."withdrawlId" = "Withdrawl".id - LEFT JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id - WHERE "Withdrawl"."userId" = $1 - AND "Withdrawl".status = 'CONFIRMED' - AND "Withdrawl".created_at < $2 - AND ("InvoiceForward"."id" IS NULL OR "Invoice"."actionType" = 'ZAP') - GROUP BY "Withdrawl".id - ORDER BY "sortTime" DESC - LIMIT ${LIMIT})` - ) - } } if (!include.size || include.has('referral')) { @@ -339,6 +321,24 @@ export default { } } + if (meFull.noteWithdrawals) { + queries.push( + `(SELECT "Withdrawl".id::text, MAX(COALESCE("Invoice"."confirmedAt", "Withdrawl".created_at)) AS "sortTime", + FLOOR(MAX("Withdrawl"."msatsPaid" / 1000)) as "earnedSats", + 'WithdrawlPaid' AS type + FROM "Withdrawl" + LEFT JOIN "InvoiceForward" ON "InvoiceForward"."withdrawlId" = "Withdrawl".id + LEFT JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id + WHERE "Withdrawl"."userId" = $1 + AND "Withdrawl".status = 'CONFIRMED' + AND "Withdrawl".created_at < $2 + AND "InvoiceForward"."id" IS NULL + GROUP BY "Withdrawl".id + ORDER BY "sortTime" DESC + LIMIT ${LIMIT})` + ) + } + if (!include.size || include.has('streak')) { if (meFull.noteCowboyHat) { queries.push( From 34e39f80a27adaf83d9696860d70dbd5062931af Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 8 Jan 2025 10:11:19 +0100 Subject: [PATCH 5/7] resolve merge conflicts --- api/resolvers/notifications.js | 36 +++++++++++++++++----------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 9ab5c5980..d4c4ebd24 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -256,6 +256,24 @@ export default { LIMIT ${LIMIT})` ) } + + if (meFull.noteWithdrawals) { + queries.push( + `(SELECT "Withdrawl".id::text, MAX(COALESCE("Invoice"."confirmedAt", "Withdrawl".created_at)) AS "sortTime", + FLOOR(MAX("Withdrawl"."msatsPaid" / 1000)) as "earnedSats", + 'WithdrawlPaid' AS type + FROM "Withdrawl" + LEFT JOIN "InvoiceForward" ON "InvoiceForward"."withdrawlId" = "Withdrawl".id + LEFT JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id + WHERE "Withdrawl"."userId" = $1 + AND "Withdrawl".status = 'CONFIRMED' + AND "Withdrawl".created_at < $2 + AND "InvoiceForward"."id" IS NULL + GROUP BY "Withdrawl".id + ORDER BY "sortTime" DESC + LIMIT ${LIMIT})` + ) + } } if (!include.size || include.has('referral')) { @@ -321,24 +339,6 @@ export default { } } - if (meFull.noteWithdrawals) { - queries.push( - `(SELECT "Withdrawl".id::text, MAX(COALESCE("Invoice"."confirmedAt", "Withdrawl".created_at)) AS "sortTime", - FLOOR(MAX("Withdrawl"."msatsPaid" / 1000)) as "earnedSats", - 'WithdrawlPaid' AS type - FROM "Withdrawl" - LEFT JOIN "InvoiceForward" ON "InvoiceForward"."withdrawlId" = "Withdrawl".id - LEFT JOIN "Invoice" ON "InvoiceForward"."invoiceId" = "Invoice".id - WHERE "Withdrawl"."userId" = $1 - AND "Withdrawl".status = 'CONFIRMED' - AND "Withdrawl".created_at < $2 - AND "InvoiceForward"."id" IS NULL - GROUP BY "Withdrawl".id - ORDER BY "sortTime" DESC - LIMIT ${LIMIT})` - ) - } - if (!include.size || include.has('streak')) { if (meFull.noteCowboyHat) { queries.push( From 7a8ed9bf948a5d73d7ff4d322a8aca2bbc8d285b Mon Sep 17 00:00:00 2001 From: Soxasora Date: Wed, 8 Jan 2025 11:34:38 +0100 Subject: [PATCH 6/7] localStorage implementation --- components/notifications-filter.js | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/components/notifications-filter.js b/components/notifications-filter.js index 20570ae04..31574033d 100644 --- a/components/notifications-filter.js +++ b/components/notifications-filter.js @@ -6,13 +6,23 @@ import { Checkbox, Form, SubmitButton } from './form' import FilterIcon from '@/svgs/equalizer-line.svg' import styles from './notifications-filter.module.css' +export const getFiltersFromInc = (inc) => { + const filters = new Set(inc?.split(',') || []) + filters.delete('') + return filters +} + +export const getSavedFilters = () => { + const savedFilters = JSON.parse(window.localStorage.getItem('notificationFilters')) + return savedFilters ? new Set(savedFilters) : new Set() +} + export function NotificationsFilter ({ onClose }) { const router = useRouter() const appliedFilters = useMemo(() => { - const filters = new Set(router.query.inc?.split(',') || []) // get filters from URL - filters.delete('') // avoid empty category - return filters + const incFilters = getFiltersFromInc(router.query.inc) + return incFilters.size ? incFilters : getSavedFilters() }, [router.query.inc]) const [filters, setFilters] = useState(appliedFilters) @@ -26,6 +36,7 @@ export function NotificationsFilter ({ onClose }) { }, []) const filterRoutePush = useCallback(() => { + window.localStorage.setItem('notificationFilters', JSON.stringify([...filters])) const incstr = [...filters].join(',') router.replace( // replace is necessary as lastChecked needs to stay to avoid re-refreshes { @@ -44,7 +55,7 @@ export function NotificationsFilter ({ onClose }) {

notifications filter

- filtering by: + filter by: {filters.size ? ` ${[...filters].join(', ')}` : ' all'}

{ + const incFilters = getFiltersFromInc(router.query.inc) + return incFilters.size > 0 + }) return (
From 05e7057bdf4efa2e02bcd04361999cbdb31fe54a Mon Sep 17 00:00:00 2001 From: Soxasora Date: Thu, 9 Jan 2025 17:14:57 +0100 Subject: [PATCH 7/7] correct UI for header; grid UI for filters; refactoring --- components/notifications-filter.js | 34 ++-------------------- components/notifications-filter.module.css | 14 --------- components/notifications.js | 34 ++++++++++++++++++++-- components/notifications.module.css | 21 +++++++++++++ pages/notifications.js | 4 +-- 5 files changed, 58 insertions(+), 49 deletions(-) delete mode 100644 components/notifications-filter.module.css diff --git a/components/notifications-filter.js b/components/notifications-filter.js index 31574033d..86d36fd1d 100644 --- a/components/notifications-filter.js +++ b/components/notifications-filter.js @@ -1,10 +1,8 @@ import { useState, useCallback, useMemo } from 'react' -import { useShowModal } from './modal' import { useRouter } from 'next/router' import { NOTIFICATION_CATEGORIES } from '../lib/constants' import { Checkbox, Form, SubmitButton } from './form' -import FilterIcon from '@/svgs/equalizer-line.svg' -import styles from './notifications-filter.module.css' +import styles from './notifications.module.css' export const getFiltersFromInc = (inc) => { const filters = new Set(inc?.split(',') || []) @@ -17,7 +15,7 @@ export const getSavedFilters = () => { return savedFilters ? new Set(savedFilters) : new Set() } -export function NotificationsFilter ({ onClose }) { +export default function NotificationsFilter ({ onClose }) { const router = useRouter() const appliedFilters = useMemo(() => { @@ -65,7 +63,7 @@ export function NotificationsFilter ({ onClose }) { onClose?.() }} > -
+
{NOTIFICATION_CATEGORIES.map((category) => ( ) } - -export default function NotificationsHeader () { - const showModal = useShowModal() - const router = useRouter() - - const hasActiveFilters = useMemo(() => { - const incFilters = getFiltersFromInc(router.query.inc) - return incFilters.size > 0 - }) - - return ( -
-

notifications

- { - showModal((onClose) => ( - - )) - }} - /> -
- ) -} diff --git a/components/notifications-filter.module.css b/components/notifications-filter.module.css deleted file mode 100644 index 456ced399..000000000 --- a/components/notifications-filter.module.css +++ /dev/null @@ -1,14 +0,0 @@ -.filterIcon { - cursor: pointer; - fill: var(--theme-grey); -} -.filterIcon:hover { - fill: var(--theme-brandColor); - filter: drop-shadow(0 0 2px var(--bs-primary)); -} - -.filterIconActive { - cursor: pointer; - fill: var(--theme-brandColor); - filter: drop-shadow(0 0 2px var(--bs-primary)); -} \ No newline at end of file diff --git a/components/notifications.js b/components/notifications.js index eba3accf1..1cf004974 100644 --- a/components/notifications.js +++ b/components/notifications.js @@ -40,10 +40,12 @@ import { paidActionCacheMods } from './use-paid-mutation' import { useRetryCreateItem } from './use-item-submit' import { payBountyCacheMods } from './pay-bounty' import { useToast } from './toast' +import { useShowModal } from './modal' +import NotificationsFilter, { getFiltersFromInc } from './notifications-filter' import classNames from 'classnames' import HolsterIcon from '@/svgs/holster.svg' import SaddleIcon from '@/svgs/saddle.svg' -import NotificationsHeader from './notifications-filter' +import FilterIcon from '@/svgs/equalizer-line.svg' function Notification ({ n, fresh }) { const type = n.__typename @@ -741,6 +743,35 @@ export function NotificationAlert () { ) } +export function NotificationsHeader () { + const showModal = useShowModal() + const router = useRouter() + + const hasActiveFilters = useMemo(() => { + const incFilters = getFiltersFromInc(router.query.inc) + return incFilters.size > 0 + }) + + return ( +
+
+

notifications

+ { + showModal((onClose) => ( + + )) + }} + /> +
+ +
+ ) +} + const nid = n => n.__typename + n.id + n.sortTime export default function Notifications ({ ssrData }) { @@ -776,7 +807,6 @@ export default function Notifications ({ ssrData }) { return ( <> - {notifications.map(n => - + )