From 72d254b4dbd3344a6a3ee95310bd4eb03ca01db1 Mon Sep 17 00:00:00 2001 From: Nutomic Date: Thu, 17 Jul 2025 23:04:09 +0000 Subject: [PATCH] Notifications rewrite and post following (fixes #3069) (#5604) * Add post_actions.disable_notifications (fixes #3042) * Split up logic for send_local_notifs() * refactor * fmt * add api endpoint, check * dont call send_local_notifs from comment delete/remove * move scrape_text_for_mentions() inside send_local_notifs() * nullable * simplify * handle parent notification first * cleanup * remove `CommentResponse.recipient_ids` * post notifications enum * Follow posts (fixes #3069) * use references * cleanup * new file * db migration to merge mention, reply tables * code adjustments * adjust test code * adjust enum case * wip: get rid of inbox_combined table * add table local_user_notification * tests compiling * get rid of inbox_combined, only use notification table * rename view * notify private messages * clippy * copy existing data * wip: tests * move tests * clippy * notify post subscribers * community subscribe * test fixes * migration fix * rename routes * separate struct for api * rename api params * merge migrations * separate notification modes for post/community * fix * down migration copy data * test fix * update api tests * only single notification table * clippy * use local user id for recipient * fix comments * rename table * recipient local user * add indices * keep local user id * renames and cleanup * NotificationDataType * change notification response * fix api tests * test fix * remove private_message.read * fixes * test fix * fix --- Cargo.lock | 46 +- Cargo.toml | 4 +- api_tests/package.json | 2 +- api_tests/pnpm-lock.yaml | 10 +- api_tests/src/comment.spec.ts | 82 +- api_tests/src/community.spec.ts | 1 - api_tests/src/post.spec.ts | 16 +- api_tests/src/private_message.spec.ts | 50 +- api_tests/src/shared.ts | 12 +- crates/api/api/Cargo.toml | 2 +- crates/api/api/src/comment/distinguish.rs | 5 +- crates/api/api/src/comment/like.rs | 16 +- crates/api/api/src/comment/save.rs | 5 +- crates/api/api/src/community/mod.rs | 1 + .../api/src/community/update_notifications.rs | 23 + crates/api/api/src/lib.rs | 1 - crates/api/api/src/local_user/export_data.rs | 20 +- .../api/src/local_user/notifications/list.rs | 46 + .../local_user/notifications/list_inbox.rs | 47 - .../local_user/notifications/mark_all_read.rs | 21 +- .../mark_comment_mention_read.rs | 35 - .../notifications/mark_notification_read.rs | 22 + .../notifications/mark_post_mention_read.rs | 35 - .../notifications/mark_reply_read.rs | 35 - .../api/src/local_user/notifications/mod.rs | 6 +- .../local_user/notifications/unread_count.rs | 10 +- crates/api/api/src/post/mod.rs | 1 + .../api/api/src/post/update_notifications.rs | 23 + .../api/api/src/private_message/mark_read.rs | 38 - crates/api/api/src/private_message/mod.rs | 1 - .../site/registration_applications/tests.rs | 2 +- .../registration_applications/unread_count.rs | 2 +- crates/api/api_common/Cargo.toml | 4 +- crates/api/api_common/src/inbox.rs | 24 - crates/api/api_common/src/lib.rs | 2 +- crates/api/api_common/src/notification.rs | 11 + crates/api/api_common/src/site.rs | 2 +- crates/api/api_crud/src/comment/create.rs | 56 +- crates/api/api_crud/src/comment/delete.rs | 14 +- crates/api/api_crud/src/comment/read.rs | 9 +- crates/api/api_crud/src/comment/remove.rs | 14 +- crates/api/api_crud/src/comment/update.rs | 20 +- crates/api/api_crud/src/post/create.rs | 22 +- crates/api/api_crud/src/post/update.rs | 18 +- .../api_crud/src/private_message/create.rs | 15 +- .../api_crud/src/private_message/update.rs | 3 + crates/api/api_utils/Cargo.toml | 2 + crates/api/api_utils/src/build_response.rs | 250 +---- crates/api/api_utils/src/lib.rs | 1 + crates/api/api_utils/src/notify.rs | 765 +++++++++++++++ crates/api/api_utils/src/utils.rs | 17 +- crates/apub/Cargo.toml | 6 +- .../activities/create_or_update/comment.rs | 28 +- .../src/activities/create_or_update/post.rs | 26 +- crates/apub_objects/Cargo.toml | 7 +- .../src/objects/private_message.rs | 5 +- crates/db_schema/src/impls/comment_reply.rs | 94 -- crates/db_schema/src/impls/community.rs | 59 +- crates/db_schema/src/impls/mod.rs | 4 +- crates/db_schema/src/impls/notification.rs | 69 ++ .../src/impls/person_comment_mention.rs | 84 -- .../src/impls/person_post_mention.rs | 87 -- crates/db_schema/src/impls/post.rs | 48 +- crates/db_schema/src/impls/private_message.rs | 27 - crates/db_schema/src/lib.rs | 8 +- crates/db_schema/src/newtypes.rs | 24 +- crates/db_schema/src/source/combined/inbox.rs | 35 - crates/db_schema/src/source/combined/mod.rs | 1 - crates/db_schema/src/source/comment_reply.rs | 38 - crates/db_schema/src/source/community.rs | 7 +- crates/db_schema/src/source/mod.rs | 4 +- crates/db_schema/src/source/notification.rs | 77 ++ .../src/source/person_comment_mention.rs | 38 - .../src/source/person_post_mention.rs | 38 - crates/db_schema/src/source/post.rs | 2 + .../db_schema/src/source/private_message.rs | 4 - crates/db_schema_file/src/enums.rs | 56 ++ crates/db_schema_file/src/schema.rs | 95 +- .../replaceable_schema/triggers.sql | 27 - crates/db_schema_setup/src/lib.rs | 12 +- crates/db_views/comment/src/api.rs | 10 +- crates/db_views/community/src/api.rs | 11 +- crates/db_views/inbox_combined/src/impls.rs | 901 ------------------ crates/db_views/inbox_combined/src/lib.rs | 233 ----- .../Cargo.toml | 16 +- .../src/api.rs | 29 +- crates/db_views/notification/src/impls.rs | 337 +++++++ crates/db_views/notification/src/lib.rs | 149 +++ crates/db_views/post/src/api.rs | 11 +- crates/db_views/site/src/api.rs | 2 +- crates/email/src/notifications.rs | 99 +- crates/email/translations | 2 +- crates/routes/Cargo.toml | 2 +- crates/routes/src/feeds.rs | 62 +- crates/utils/src/error.rs | 9 +- .../down.sql | 140 +++ .../up.sql | 104 ++ src/api_routes.rs | 26 +- 98 files changed, 2336 insertions(+), 2686 deletions(-) create mode 100644 crates/api/api/src/community/update_notifications.rs create mode 100644 crates/api/api/src/local_user/notifications/list.rs delete mode 100644 crates/api/api/src/local_user/notifications/list_inbox.rs delete mode 100644 crates/api/api/src/local_user/notifications/mark_comment_mention_read.rs create mode 100644 crates/api/api/src/local_user/notifications/mark_notification_read.rs delete mode 100644 crates/api/api/src/local_user/notifications/mark_post_mention_read.rs delete mode 100644 crates/api/api/src/local_user/notifications/mark_reply_read.rs create mode 100644 crates/api/api/src/post/update_notifications.rs delete mode 100644 crates/api/api/src/private_message/mark_read.rs delete mode 100644 crates/api/api/src/private_message/mod.rs delete mode 100644 crates/api/api_common/src/inbox.rs create mode 100644 crates/api/api_common/src/notification.rs create mode 100644 crates/api/api_utils/src/notify.rs delete mode 100644 crates/db_schema/src/impls/comment_reply.rs create mode 100644 crates/db_schema/src/impls/notification.rs delete mode 100644 crates/db_schema/src/impls/person_comment_mention.rs delete mode 100644 crates/db_schema/src/impls/person_post_mention.rs delete mode 100644 crates/db_schema/src/source/combined/inbox.rs delete mode 100644 crates/db_schema/src/source/comment_reply.rs create mode 100644 crates/db_schema/src/source/notification.rs delete mode 100644 crates/db_schema/src/source/person_comment_mention.rs delete mode 100644 crates/db_schema/src/source/person_post_mention.rs delete mode 100644 crates/db_views/inbox_combined/src/impls.rs delete mode 100644 crates/db_views/inbox_combined/src/lib.rs rename crates/db_views/{inbox_combined => notification}/Cargo.toml (76%) rename crates/db_views/{inbox_combined => notification}/src/api.rs (57%) create mode 100644 crates/db_views/notification/src/impls.rs create mode 100644 crates/db_views/notification/src/lib.rs create mode 100644 migrations/2025-07-17-103657_post-or-comment-notification/down.sql create mode 100644 migrations/2025-07-17-103657_post-or-comment-notification/up.sql diff --git a/Cargo.lock b/Cargo.lock index 589666132..f01fc60e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3107,10 +3107,10 @@ dependencies = [ "lemmy_db_views_community_follower", "lemmy_db_views_community_moderator", "lemmy_db_views_community_person_ban", - "lemmy_db_views_inbox_combined", "lemmy_db_views_local_image", "lemmy_db_views_local_user", "lemmy_db_views_modlog_combined", + "lemmy_db_views_notification", "lemmy_db_views_person", "lemmy_db_views_person_content_combined", "lemmy_db_views_person_liked_combined", @@ -3143,10 +3143,10 @@ dependencies = [ "lemmy_db_views_community_follower", "lemmy_db_views_community_moderator", "lemmy_db_views_custom_emoji", - "lemmy_db_views_inbox_combined", "lemmy_db_views_local_image", "lemmy_db_views_local_user", "lemmy_db_views_modlog_combined", + "lemmy_db_views_notification", "lemmy_db_views_person", "lemmy_db_views_person_content_combined", "lemmy_db_views_person_liked_combined", @@ -3209,6 +3209,7 @@ dependencies = [ "actix-web-httpauth", "anyhow", "chrono", + "derive-new", "diesel_ltree", "either", "encoding_rs", @@ -3227,6 +3228,7 @@ dependencies = [ "lemmy_db_views_community_person_ban", "lemmy_db_views_local_image", "lemmy_db_views_local_user", + "lemmy_db_views_notification", "lemmy_db_views_person", "lemmy_db_views_post", "lemmy_db_views_private_message", @@ -3316,6 +3318,7 @@ dependencies = [ "lemmy_db_views_community_moderator", "lemmy_db_views_community_person_ban", "lemmy_db_views_local_user", + "lemmy_db_views_private_message", "lemmy_db_views_site", "lemmy_utils", "moka", @@ -3500,25 +3503,6 @@ dependencies = [ "url", ] -[[package]] -name = "lemmy_db_views_inbox_combined" -version = "1.0.0-alpha.5" -dependencies = [ - "diesel", - "diesel-async", - "i-love-jesus", - "lemmy_db_schema", - "lemmy_db_schema_file", - "lemmy_db_views_private_message", - "lemmy_utils", - "pretty_assertions", - "serde", - "serde_with", - "serial_test", - "tokio", - "ts-rs", -] - [[package]] name = "lemmy_db_views_local_image" version = "1.0.0-alpha.5" @@ -3572,6 +3556,24 @@ dependencies = [ "ts-rs", ] +[[package]] +name = "lemmy_db_views_notification" +version = "1.0.0-alpha.5" +dependencies = [ + "diesel", + "diesel-async", + "i-love-jesus", + "lemmy_db_schema", + "lemmy_db_schema_file", + "lemmy_db_views_comment", + "lemmy_db_views_post", + "lemmy_db_views_private_message", + "lemmy_utils", + "serde", + "serde_with", + "ts-rs", +] + [[package]] name = "lemmy_db_views_person" version = "1.0.0-alpha.5" @@ -3882,10 +3884,10 @@ dependencies = [ "lemmy_db_schema", "lemmy_db_schema_file", "lemmy_db_views_community", - "lemmy_db_views_inbox_combined", "lemmy_db_views_local_image", "lemmy_db_views_local_user", "lemmy_db_views_modlog_combined", + "lemmy_db_views_notification", "lemmy_db_views_person_content_combined", "lemmy_db_views_post", "lemmy_db_views_site", diff --git a/Cargo.toml b/Cargo.toml index e2a8a0326..531bbf767 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,7 +65,7 @@ members = [ "crates/db_views/community_follower", "crates/db_views/community_person_ban", "crates/db_views/custom_emoji", - "crates/db_views/inbox_combined", + "crates/db_views/notification", "crates/db_views/modlog_combined", "crates/db_views/person_content_combined", "crates/db_views/person_saved_combined", @@ -124,7 +124,7 @@ lemmy_db_views_community_follower = { version = "=1.0.0-alpha.5", path = "./crat lemmy_db_views_community_moderator = { version = "=1.0.0-alpha.5", path = "./crates/db_views/community_moderator" } lemmy_db_views_community_person_ban = { version = "=1.0.0-alpha.5", path = "./crates/db_views/community_person_ban" } lemmy_db_views_custom_emoji = { version = "=1.0.0-alpha.5", path = "./crates/db_views/custom_emoji" } -lemmy_db_views_inbox_combined = { version = "=1.0.0-alpha.5", path = "./crates/db_views/inbox_combined" } +lemmy_db_views_notification = { version = "=1.0.0-alpha.5", path = "./crates/db_views/notification" } lemmy_db_views_local_image = { version = "=1.0.0-alpha.5", path = "./crates/db_views/local_image" } lemmy_db_views_local_user = { version = "=1.0.0-alpha.5", path = "./crates/db_views/local_user" } lemmy_db_views_modlog_combined = { version = "=1.0.0-alpha.5", path = "./crates/db_views/modlog_combined" } diff --git a/api_tests/package.json b/api_tests/package.json index c9f093045..a7f967d67 100644 --- a/api_tests/package.json +++ b/api_tests/package.json @@ -31,7 +31,7 @@ "eslint-plugin-prettier": "^5.4.0", "jest": "^29.5.0", "joi": "^17.13.3", - "lemmy-js-client": "1.0.0-rename-rate-limit-columns.1", + "lemmy-js-client": "1.0.0-post-notifications.5", "prettier": "^3.5.3", "ts-jest": "^29.3.2", "tsoa": "^6.6.0", diff --git a/api_tests/pnpm-lock.yaml b/api_tests/pnpm-lock.yaml index e834f72c7..46fecf73b 100644 --- a/api_tests/pnpm-lock.yaml +++ b/api_tests/pnpm-lock.yaml @@ -36,8 +36,8 @@ importers: specifier: ^17.13.3 version: 17.13.3 lemmy-js-client: - specifier: 1.0.0-rename-rate-limit-columns.1 - version: 1.0.0-rename-rate-limit-columns.1 + specifier: 1.0.0-post-notifications.5 + version: 1.0.0-post-notifications.5 prettier: specifier: ^3.5.3 version: 3.5.3 @@ -1594,8 +1594,8 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} - lemmy-js-client@1.0.0-rename-rate-limit-columns.1: - resolution: {integrity: sha512-zlVJ4zkoI/7hNm6x7vr+Su2cRjAr8PQCA9j0GeK1UCMEIBLLSltknuRPC79VJY2sUhRAuR2JwTR0JtZ75SH2XQ==} + lemmy-js-client@1.0.0-post-notifications.5: + resolution: {integrity: sha512-2P0KPCordLRfuGTcgsU3pHSFJlVN5t91e04yhpUf5fZT7iTdlEctFQFtURsvfYPNYK/sdvsucqYbnpbbHJUCTA==} leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} @@ -4404,7 +4404,7 @@ snapshots: kleur@3.0.3: {} - lemmy-js-client@1.0.0-rename-rate-limit-columns.1: + lemmy-js-client@1.0.0-post-notifications.5: dependencies: '@tsoa/runtime': 6.6.0 transitivePeerDependencies: diff --git a/api_tests/src/comment.spec.ts b/api_tests/src/comment.spec.ts index 3c1ba8153..600e05e92 100644 --- a/api_tests/src/comment.spec.ts +++ b/api_tests/src/comment.spec.ts @@ -36,16 +36,14 @@ import { saveUserSettings, listReports, listPersonContent, - listInbox, + listNotifications, } from "./shared"; import { - CommentReplyView, CommentReportView, CommentView, CommunityView, DistinguishComment, LemmyError, - PersonCommentMentionView, ReportCombinedView, SaveUserSettings, } from "lemmy-js-client"; @@ -409,23 +407,25 @@ test("Reply to a comment from another instance, get notification", async () => { // check inbox of replies on alpha, fetching read/unread both let alphaRepliesRes = await waitUntil( - () => listInbox(alpha, "CommentReply"), - r => r.inbox.length > 0, + () => listNotifications(alpha, "Reply"), + r => r.notifications.length > 0, + ); + const alphaReply = alphaRepliesRes.notifications.find( + r => + r.data.type_ == "Comment" && + r.data.comment.id === alphaComment.comment.id, ); - const alphaReply = alphaRepliesRes.inbox.find( - r => r.type_ == "CommentReply" && r.comment.id === alphaComment.comment.id, - ) as CommentReplyView | undefined; expect(alphaReply).toBeDefined(); if (!alphaReply) throw Error(); - expect(alphaReply.comment.content).toBeDefined(); - expect(alphaReply.community.local).toBe(false); - expect(alphaReply.creator.local).toBe(false); - expect(alphaReply.comment.score).toBe(1); + const alphaReplyData = alphaReply.data as CommentView; + expect(alphaReplyData.comment!.content).toBeDefined(); + expect(alphaReplyData.community!.local).toBe(false); + expect(alphaReplyData.creator.local).toBe(false); + expect(alphaReplyData.comment!.score).toBe(1); // ToDo: interesting alphaRepliesRes.replies[0].comment_reply.id is 1, meaning? how did that come about? - expect(alphaReply.comment.id).toBe(alphaComment.comment.id); + expect(alphaReplyData.comment!.id).toBe(alphaComment.comment.id); // this is a new notification, getReplies fetch was for read/unread both, confirm it is unread. - expect(alphaReply.comment_reply.read).toBe(false); - assertCommentFederation(alphaReply, replyRes.comment_view); + expect(alphaReply.notification.read).toBe(false); }); test("Bot reply notifications are filtered when bots are hidden", async () => { @@ -472,9 +472,9 @@ test("Bot reply notifications are filtered when bots are hidden", async () => { alphaUnreadCountRes = await getUnreadCount(alpha); expect(alphaUnreadCountRes.count).toBe(1); - let alphaUnreadRepliesRes = await listInbox(alpha, "CommentReply", true); - expect(alphaUnreadRepliesRes.inbox.length).toBe(1); - expect((alphaUnreadRepliesRes.inbox[0] as CommentReplyView).comment.id).toBe( + let alphaUnreadRepliesRes = await listNotifications(alpha, "Reply", true); + expect(alphaUnreadRepliesRes.notifications.length).toBe(1); + expect(alphaUnreadRepliesRes.notifications[0].notification.comment_id).toBe( commentRes.comment_view.comment.id, ); }); @@ -526,17 +526,18 @@ test("Mention beta from alpha comment", async () => { assertCommentFederation(betaRootComment, commentRes.comment_view); let mentionsRes = await waitUntil( - () => listInbox(beta, "CommentMention"), - m => !!m.inbox[0], + () => listNotifications(beta, "Mention"), + m => !!m.notifications[0], ); - const firstMention = mentionsRes.inbox[0] as PersonCommentMentionView; - expect(firstMention.comment.content).toBeDefined(); - expect(firstMention.community.local).toBe(true); - expect(firstMention.creator.local).toBe(false); - expect(firstMention.comment.score).toBe(1); + const firstMention = mentionsRes.notifications[0]; + let firstMentionData = firstMention.data as CommentView; + expect(firstMentionData.comment!.content).toBeDefined(); + expect(firstMentionData.community!.local).toBe(true); + expect(firstMentionData.creator.local).toBe(false); + expect(firstMentionData.comment!.score).toBe(1); // the reply comment with mention should be the most fresh, newest, index 0 - expect(firstMention.person_comment_mention.comment_id).toBe( + expect(firstMentionData.comment!.id).toBe( betaPostComments.comments[0].comment.id, ); }); @@ -599,21 +600,24 @@ test("A and G subscribe to B (center) A posts, G mentions B, it gets announced t ); // Make sure beta has mentions - let relevantMention = (await waitUntil( + let relevantMention = await waitUntil( () => - listInbox(beta, "CommentMention").then(m => - m.inbox.find( - m => - m.type_ == "CommentMention" && - m.comment.ap_id === commentRes.comment_view.comment.ap_id, - ), + listNotifications(beta, "Mention").then(m => + m.notifications.find(m => { + let data = m.data as CommentView; + return ( + m.notification.kind == "Mention" && + data.comment.ap_id === commentRes.comment_view.comment.ap_id + ); + }), ), e => !!e, - )) as PersonCommentMentionView | undefined; + ); if (!relevantMention) throw Error("could not find mention"); - expect(relevantMention.comment.content).toBe(commentContent); - expect(relevantMention.community.local).toBe(false); - expect(relevantMention.creator.local).toBe(false); + let relevantMentionData = relevantMention.data as CommentView; + expect(relevantMentionData.comment!.content).toBe(commentContent); + expect(relevantMentionData.community!.local).toBe(false); + expect(relevantMentionData.creator.local).toBe(false); // TODO this is failing because fetchInReplyTos aren't getting score // expect(mentionsRes.mentions[0].score).toBe(1); }); @@ -839,8 +843,8 @@ test("Dont send a comment reply to a blocked community", async () => { unreadCount = await getUnreadCount(beta); expect(unreadCount.count).toBe(0); - let replies = await listInbox(beta, "CommentReply", true); - expect(replies.inbox.length).toBe(0); + let replies = await listNotifications(beta, "Reply", true); + expect(replies.notifications.length).toBe(0); // Unblock the community blockRes = await blockCommunity(beta, newCommunityId, false); diff --git a/api_tests/src/community.spec.ts b/api_tests/src/community.spec.ts index 59d76acd4..66b10aa3b 100644 --- a/api_tests/src/community.spec.ts +++ b/api_tests/src/community.spec.ts @@ -45,7 +45,6 @@ import { FollowMultiCommunity, GetPosts, LemmyError, - MultiCommunity, MultiCommunityView, ReportCombinedView, ResolveCommunityReport, diff --git a/api_tests/src/post.spec.ts b/api_tests/src/post.spec.ts index d38abc418..3a0451810 100644 --- a/api_tests/src/post.spec.ts +++ b/api_tests/src/post.spec.ts @@ -38,7 +38,7 @@ import { createCommunity, listReports, getMyUser, - listInbox, + listNotifications, getModlog, getCommunity, } from "./shared"; @@ -48,7 +48,6 @@ import { AddModToCommunity, EditSite, EditPost, - PersonPostMentionView, PostReport, PostReportView, ReportCombinedView, @@ -942,16 +941,15 @@ test("Mention beta from alpha post body", async () => { await assertPostFederation(betaPost, postOnAlphaRes.post_view); let mentionsRes = await waitUntil( - () => listInbox(beta, "PostMention"), - m => !!m.inbox[0], + () => listNotifications(beta, "Mention"), + m => !!m.notifications[0], ); - const firstMention = mentionsRes.inbox[0] as PersonPostMentionView; - expect(firstMention.post.body).toBeDefined(); - expect(firstMention.community.local).toBe(true); + const firstMention = mentionsRes.notifications[0].data as PostView; + expect(firstMention.post!.body).toBeDefined(); + expect(firstMention.community!.local).toBe(true); expect(firstMention.creator.local).toBe(false); - expect(firstMention.post.score).toBe(1); - expect(firstMention.person_post_mention.post_id).toBe(betaPost.post.id); + expect(firstMention.post!.score).toBe(1); }); test("Rewrite markdown links", async () => { diff --git a/api_tests/src/private_message.spec.ts b/api_tests/src/private_message.spec.ts index a611e3819..d318aabbe 100644 --- a/api_tests/src/private_message.spec.ts +++ b/api_tests/src/private_message.spec.ts @@ -4,14 +4,13 @@ import { alpha, beta, setupLogins, - followBeta, createPrivateMessage, editPrivateMessage, deletePrivateMessage, waitUntil, reportPrivateMessage, unfollows, - listInbox, + listNotifications, resolvePerson, } from "./shared"; @@ -37,10 +36,10 @@ test("Create a private message", async () => { expect(pmRes.private_message_view.recipient.local).toBe(false); let betaPms = await waitUntil( - () => listInbox(beta, "PrivateMessage"), - e => !!e.inbox[0], + () => listNotifications(beta, "PrivateMessage"), + e => !!e.notifications[0], ); - const firstPm = betaPms.inbox[0] as PrivateMessageView; + const firstPm = betaPms.notifications[0].data as PrivateMessageView; expect(firstPm.private_message.content).toBeDefined(); expect(firstPm.private_message.local).toBe(false); expect(firstPm.creator.local).toBe(false); @@ -60,25 +59,24 @@ test("Update a private message", async () => { ); let betaPms = await waitUntil( - () => listInbox(beta, "PrivateMessage"), + () => listNotifications(beta, "PrivateMessage"), p => - p.inbox[0].type_ == "PrivateMessage" && - p.inbox[0].private_message.content === updatedContent, - ); - expect((betaPms.inbox[0] as PrivateMessageView).private_message.content).toBe( - updatedContent, + p.notifications[0].data.type_ == "PrivateMessage" && + p.notifications[0].data.private_message.content === updatedContent, ); + let pm = betaPms.notifications[0].data as PrivateMessageView; + expect(pm.private_message.content).toBe(updatedContent); }); test("Delete a private message", async () => { let pmRes = await createPrivateMessage(alpha, recipient_id); let betaPms1 = await waitUntil( - () => listInbox(beta, "PrivateMessage"), + () => listNotifications(beta, "PrivateMessage"), m => - !!m.inbox.find( + !!m.notifications.find( e => - e.type_ == "PrivateMessage" && - e.private_message.ap_id === + e.data.type_ == "PrivateMessage" && + e.data.private_message.ap_id === pmRes.private_message_view.private_message.ap_id, ), ); @@ -93,10 +91,10 @@ test("Delete a private message", async () => { // even though they are in the actual database. // no reason to show them let betaPms2 = await waitUntil( - () => listInbox(beta, "PrivateMessage"), - p => p.inbox.length === betaPms1.inbox.length - 1, + () => listNotifications(beta, "PrivateMessage"), + p => p.notifications.length === betaPms1.notifications.length - 1, ); - expect(betaPms2.inbox.length).toBe(betaPms1.inbox.length - 1); + expect(betaPms2.notifications.length).toBe(betaPms1.notifications.length - 1); // Undelete let undeletedPmRes = await deletePrivateMessage( @@ -109,25 +107,25 @@ test("Delete a private message", async () => { ); let betaPms3 = await waitUntil( - () => listInbox(beta, "PrivateMessage"), - p => p.inbox.length === betaPms1.inbox.length, + () => listNotifications(beta, "PrivateMessage"), + p => p.notifications.length === betaPms1.notifications.length, ); - expect(betaPms3.inbox.length).toBe(betaPms1.inbox.length); + expect(betaPms3.notifications.length).toBe(betaPms1.notifications.length); }); test("Create a private message report", async () => { let pmRes = await createPrivateMessage(alpha, recipient_id); let betaPms1 = await waitUntil( - () => listInbox(beta, "PrivateMessage"), + () => listNotifications(beta, "PrivateMessage"), m => - !!m.inbox.find( + !!m.notifications.find( e => - e.type_ == "PrivateMessage" && - e.private_message.ap_id === + e.data.type_ == "PrivateMessage" && + e.data.private_message.ap_id === pmRes.private_message_view.private_message.ap_id, ), ); - let betaPm = betaPms1.inbox[0] as PrivateMessageView; + let betaPm = betaPms1.notifications[0].data as PrivateMessageView; expect(betaPm).toBeDefined(); // Make sure that only the recipient can report it, so this should fail diff --git a/api_tests/src/shared.ts b/api_tests/src/shared.ts index baa58d21e..0aa7e1aec 100644 --- a/api_tests/src/shared.ts +++ b/api_tests/src/shared.ts @@ -24,14 +24,14 @@ import { ListPersonContentResponse, ListPersonContent, PersonContentType, - ListInboxResponse, - ListInbox, InboxDataType, GetModlogResponse, GetModlog, CommunityView, CommentView, PersonView, + ListNotifications, + ListNotificationsResponse, } from "lemmy-js-client"; import { CreatePost } from "lemmy-js-client/dist/types/CreatePost"; import { DeletePost } from "lemmy-js-client/dist/types/DeletePost"; @@ -384,16 +384,16 @@ export async function getUnreadCount( return api.getUnreadCount(); } -export async function listInbox( +export async function listNotifications( api: LemmyHttp, type_?: InboxDataType, unread_only: boolean = false, -): Promise { - let form: ListInbox = { +): Promise { + let form: ListNotifications = { unread_only, type_, }; - return api.listInbox(form); + return api.listNotifications(form); } export async function resolveComment( diff --git a/crates/api/api/Cargo.toml b/crates/api/api/Cargo.toml index 571c1a852..0d21aa160 100644 --- a/crates/api/api/Cargo.toml +++ b/crates/api/api/Cargo.toml @@ -29,7 +29,7 @@ lemmy_db_views_vote = { workspace = true, features = ["full"] } lemmy_db_views_local_user = { workspace = true, features = ["full"] } lemmy_db_views_person = { workspace = true, features = ["full"] } lemmy_db_views_local_image = { workspace = true, features = ["full"] } -lemmy_db_views_inbox_combined = { workspace = true, features = ["full"] } +lemmy_db_views_notification = { workspace = true, features = ["full"] } lemmy_db_views_modlog_combined = { workspace = true, features = ["full"] } lemmy_db_views_person_saved_combined = { workspace = true, features = ["full"] } lemmy_db_views_person_liked_combined = { workspace = true, features = ["full"] } diff --git a/crates/api/api/src/comment/distinguish.rs b/crates/api/api/src/comment/distinguish.rs index 186fa47e9..35bcd66d1 100644 --- a/crates/api/api/src/comment/distinguish.rs +++ b/crates/api/api/src/comment/distinguish.rs @@ -69,8 +69,5 @@ pub async fn distinguish_comment( ) .await?; - Ok(Json(CommentResponse { - comment_view, - recipient_ids: Vec::new(), - })) + Ok(Json(CommentResponse { comment_view })) } diff --git a/crates/api/api/src/comment/like.rs b/crates/api/api/src/comment/like.rs index 404066abf..acc9c01a0 100644 --- a/crates/api/api/src/comment/like.rs +++ b/crates/api/api/src/comment/like.rs @@ -8,10 +8,9 @@ use lemmy_api_utils::{ utils::{check_bot_account, check_community_user_action, check_local_vote_mode}, }; use lemmy_db_schema::{ - newtypes::{LocalUserId, PostOrCommentId}, + newtypes::PostOrCommentId, source::{ comment::{CommentActions, CommentLikeForm}, - comment_reply::CommentReply, person::PersonActions, }, traits::Likeable, @@ -35,8 +34,6 @@ pub async fn like_comment( let comment_id = data.comment_id; let my_person_id = local_user_view.person.id; - let mut recipient_ids = Vec::::new(); - check_local_vote_mode( data.score, PostOrCommentId::Comment(comment_id), @@ -63,16 +60,6 @@ pub async fn like_comment( ) .await?; - // Add parent poster or commenter to recipients - let comment_reply = CommentReply::read_by_comment(&mut context.pool(), comment_id).await; - if let Ok(Some(reply)) = comment_reply { - let recipient_id = reply.recipient_id; - if let Ok(local_recipient) = LocalUserView::read_person(&mut context.pool(), recipient_id).await - { - recipient_ids.push(local_recipient.local_user.id); - } - } - let mut like_form = CommentLikeForm::new(my_person_id, data.comment_id, data.score); // Remove any likes first @@ -119,7 +106,6 @@ pub async fn like_comment( context.deref(), comment_id, Some(local_user_view), - recipient_ids, local_instance_id, ) .await?, diff --git a/crates/api/api/src/comment/save.rs b/crates/api/api/src/comment/save.rs index 41782f85d..386961754 100644 --- a/crates/api/api/src/comment/save.rs +++ b/crates/api/api/src/comment/save.rs @@ -34,8 +34,5 @@ pub async fn save_comment( ) .await?; - Ok(Json(CommentResponse { - comment_view, - recipient_ids: Vec::new(), - })) + Ok(Json(CommentResponse { comment_view })) } diff --git a/crates/api/api/src/community/mod.rs b/crates/api/api/src/community/mod.rs index e8d1661a0..424f4407c 100644 --- a/crates/api/api/src/community/mod.rs +++ b/crates/api/api/src/community/mod.rs @@ -7,3 +7,4 @@ pub mod pending_follows; pub mod random; pub mod tag; pub mod transfer; +pub mod update_notifications; diff --git a/crates/api/api/src/community/update_notifications.rs b/crates/api/api/src/community/update_notifications.rs new file mode 100644 index 000000000..b011e4547 --- /dev/null +++ b/crates/api/api/src/community/update_notifications.rs @@ -0,0 +1,23 @@ +use activitypub_federation::config::Data; +use actix_web::web::Json; +use lemmy_api_utils::context::LemmyContext; +use lemmy_db_schema::source::community::CommunityActions; +use lemmy_db_views_community::api::UpdateCommunityNotifications; +use lemmy_db_views_local_user::LocalUserView; +use lemmy_db_views_site::api::SuccessResponse; +use lemmy_utils::error::LemmyResult; + +pub async fn update_community_notifications( + data: Json, + context: Data, + local_user_view: LocalUserView, +) -> LemmyResult> { + CommunityActions::update_notification_state( + data.community_id, + local_user_view.person.id, + data.mode, + &mut context.pool(), + ) + .await?; + Ok(Json(SuccessResponse::default())) +} diff --git a/crates/api/api/src/lib.rs b/crates/api/api/src/lib.rs index 4c943e9ef..28da26d69 100644 --- a/crates/api/api/src/lib.rs +++ b/crates/api/api/src/lib.rs @@ -13,7 +13,6 @@ pub mod comment; pub mod community; pub mod local_user; pub mod post; -pub mod private_message; pub mod reports; pub mod site; pub mod sitemap; diff --git a/crates/api/api/src/local_user/export_data.rs b/crates/api/api/src/local_user/export_data.rs index 082ea8aa9..2910cf1e3 100644 --- a/crates/api/api/src/local_user/export_data.rs +++ b/crates/api/api/src/local_user/export_data.rs @@ -3,8 +3,8 @@ use actix_web::web::Json; use lemmy_api_utils::context::LemmyContext; use lemmy_db_schema::source::local_user::LocalUser; use lemmy_db_views_community_moderator::CommunityModeratorView; -use lemmy_db_views_inbox_combined::{impls::InboxCombinedQuery, InboxCombinedView}; use lemmy_db_views_local_user::LocalUserView; +use lemmy_db_views_notification::{impls::NotificationQuery, NotificationData}; use lemmy_db_views_person_content_combined::{ impls::PersonContentCombinedQuery, PersonContentCombinedView, @@ -46,18 +46,18 @@ pub async fn export_data( }) .collect(); - let inbox = InboxCombinedQuery { + let notifications = NotificationQuery { no_limit: Some(true), - ..InboxCombinedQuery::default() + show_bot_accounts: Some(local_user_view.local_user.show_bot_accounts), + ..NotificationQuery::default() } - .list(pool, my_person_id, local_instance_id) + .list(pool, &local_user_view.person) .await? .into_iter() - .map(|u| match u { - InboxCombinedView::CommentReply(cr) => Comment(cr.comment), - InboxCombinedView::CommentMention(cm) => Comment(cm.comment), - InboxCombinedView::PostMention(pm) => Post(pm.post), - InboxCombinedView::PrivateMessage(pm) => PrivateMessage(pm.private_message), + .map(|u| match u.data { + NotificationData::Post(p) => Post(p.post), + NotificationData::Comment(c) => Comment(c.comment), + NotificationData::PrivateMessage(pm) => PrivateMessage(pm.private_message), }) .collect(); @@ -93,7 +93,7 @@ pub async fn export_data( let settings = user_backup_list_to_user_settings_backup(local_user_view, lists); Ok(Json(ExportDataResponse { - inbox, + notifications, content, liked, read_posts, diff --git a/crates/api/api/src/local_user/notifications/list.rs b/crates/api/api/src/local_user/notifications/list.rs new file mode 100644 index 000000000..92263aa5b --- /dev/null +++ b/crates/api/api/src/local_user/notifications/list.rs @@ -0,0 +1,46 @@ +use actix_web::web::{Data, Json, Query}; +use lemmy_api_utils::context::LemmyContext; +use lemmy_db_schema::traits::PaginationCursorBuilder; +use lemmy_db_views_local_user::LocalUserView; +use lemmy_db_views_notification::{ + impls::NotificationQuery, + ListNotifications, + ListNotificationsResponse, + NotificationView, +}; +use lemmy_utils::error::LemmyResult; + +pub async fn list_notifications( + data: Query, + context: Data, + local_user_view: LocalUserView, +) -> LemmyResult> { + let cursor_data = if let Some(cursor) = &data.page_cursor { + Some(NotificationView::from_cursor(cursor, &mut context.pool()).await?) + } else { + None + }; + + let notifications = NotificationQuery { + type_: data.type_, + unread_only: data.unread_only, + show_bot_accounts: Some(local_user_view.local_user.show_bot_accounts), + cursor_data, + page_back: data.page_back, + limit: data.limit, + no_limit: None, + } + .list(&mut context.pool(), &local_user_view.person) + .await?; + + let next_page = notifications.last().map(PaginationCursorBuilder::to_cursor); + let prev_page = notifications + .first() + .map(PaginationCursorBuilder::to_cursor); + + Ok(Json(ListNotificationsResponse { + notifications, + next_page, + prev_page, + })) +} diff --git a/crates/api/api/src/local_user/notifications/list_inbox.rs b/crates/api/api/src/local_user/notifications/list_inbox.rs deleted file mode 100644 index 39150b9c2..000000000 --- a/crates/api/api/src/local_user/notifications/list_inbox.rs +++ /dev/null @@ -1,47 +0,0 @@ -use actix_web::web::{Data, Json, Query}; -use lemmy_api_utils::context::LemmyContext; -use lemmy_db_schema::traits::PaginationCursorBuilder; -use lemmy_db_views_inbox_combined::{ - impls::InboxCombinedQuery, - InboxCombinedView, - ListInbox, - ListInboxResponse, -}; -use lemmy_db_views_local_user::LocalUserView; -use lemmy_utils::error::LemmyResult; - -pub async fn list_inbox( - data: Query, - context: Data, - local_user_view: LocalUserView, -) -> LemmyResult> { - let person_id = local_user_view.person.id; - let local_instance_id = local_user_view.person.instance_id; - - let cursor_data = if let Some(cursor) = &data.page_cursor { - Some(InboxCombinedView::from_cursor(cursor, &mut context.pool()).await?) - } else { - None - }; - - let inbox = InboxCombinedQuery { - type_: data.type_, - unread_only: data.unread_only, - show_bot_accounts: Some(local_user_view.local_user.show_bot_accounts), - cursor_data, - page_back: data.page_back, - limit: data.limit, - no_limit: None, - } - .list(&mut context.pool(), person_id, local_instance_id) - .await?; - - let next_page = inbox.last().map(PaginationCursorBuilder::to_cursor); - let prev_page = inbox.first().map(PaginationCursorBuilder::to_cursor); - - Ok(Json(ListInboxResponse { - inbox, - next_page, - prev_page, - })) -} diff --git a/crates/api/api/src/local_user/notifications/mark_all_read.rs b/crates/api/api/src/local_user/notifications/mark_all_read.rs index ff315fd95..aee46c524 100644 --- a/crates/api/api/src/local_user/notifications/mark_all_read.rs +++ b/crates/api/api/src/local_user/notifications/mark_all_read.rs @@ -1,11 +1,6 @@ use actix_web::web::{Data, Json}; use lemmy_api_utils::context::LemmyContext; -use lemmy_db_schema::source::{ - comment_reply::CommentReply, - person_comment_mention::PersonCommentMention, - person_post_mention::PersonPostMention, - private_message::PrivateMessage, -}; +use lemmy_db_schema::source::notification::Notification; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::api::SuccessResponse; use lemmy_utils::error::LemmyResult; @@ -14,19 +9,7 @@ pub async fn mark_all_notifications_read( context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { - let person_id = local_user_view.person.id; - - // Mark all comment_replies as read - CommentReply::mark_all_as_read(&mut context.pool(), person_id).await?; - - // Mark all comment mentions as read - PersonCommentMention::mark_all_as_read(&mut context.pool(), person_id).await?; - - // Mark all post mentions as read - PersonPostMention::mark_all_as_read(&mut context.pool(), person_id).await?; - - // Mark all private_messages as read - PrivateMessage::mark_all_as_read(&mut context.pool(), person_id).await?; + Notification::mark_all_as_read(&mut context.pool(), local_user_view.person.id).await?; Ok(Json(SuccessResponse::default())) } diff --git a/crates/api/api/src/local_user/notifications/mark_comment_mention_read.rs b/crates/api/api/src/local_user/notifications/mark_comment_mention_read.rs deleted file mode 100644 index 6f7b71105..000000000 --- a/crates/api/api/src/local_user/notifications/mark_comment_mention_read.rs +++ /dev/null @@ -1,35 +0,0 @@ -use actix_web::web::{Data, Json}; -use lemmy_api_utils::context::LemmyContext; -use lemmy_db_schema::{ - source::person_comment_mention::{PersonCommentMention, PersonCommentMentionUpdateForm}, - traits::Crud, -}; -use lemmy_db_views_inbox_combined::api::MarkPersonCommentMentionAsRead; -use lemmy_db_views_local_user::LocalUserView; -use lemmy_db_views_site::api::SuccessResponse; -use lemmy_utils::error::{LemmyErrorType, LemmyResult}; - -pub async fn mark_comment_mention_as_read( - data: Json, - context: Data, - local_user_view: LocalUserView, -) -> LemmyResult> { - let person_comment_mention_id = data.person_comment_mention_id; - let read_person_comment_mention = - PersonCommentMention::read(&mut context.pool(), person_comment_mention_id).await?; - - if local_user_view.person.id != read_person_comment_mention.recipient_id { - Err(LemmyErrorType::CouldntUpdateComment)? - } - - let person_comment_mention_id = read_person_comment_mention.id; - let read = Some(data.read); - PersonCommentMention::update( - &mut context.pool(), - person_comment_mention_id, - &PersonCommentMentionUpdateForm { read }, - ) - .await?; - - Ok(Json(SuccessResponse::default())) -} diff --git a/crates/api/api/src/local_user/notifications/mark_notification_read.rs b/crates/api/api/src/local_user/notifications/mark_notification_read.rs new file mode 100644 index 000000000..b05806846 --- /dev/null +++ b/crates/api/api/src/local_user/notifications/mark_notification_read.rs @@ -0,0 +1,22 @@ +use actix_web::web::{Data, Json}; +use lemmy_api_utils::context::LemmyContext; +use lemmy_db_schema::source::notification::Notification; +use lemmy_db_views_local_user::LocalUserView; +use lemmy_db_views_notification::api::MarkNotificationAsRead; +use lemmy_db_views_site::api::SuccessResponse; +use lemmy_utils::error::LemmyResult; + +pub async fn mark_notification_as_read( + data: Json, + context: Data, + local_user_view: LocalUserView, +) -> LemmyResult> { + Notification::mark_read_by_id_and_person( + &mut context.pool(), + data.notification_id, + local_user_view.person.id, + ) + .await?; + + Ok(Json(SuccessResponse::default())) +} diff --git a/crates/api/api/src/local_user/notifications/mark_post_mention_read.rs b/crates/api/api/src/local_user/notifications/mark_post_mention_read.rs deleted file mode 100644 index 9e0dafaf4..000000000 --- a/crates/api/api/src/local_user/notifications/mark_post_mention_read.rs +++ /dev/null @@ -1,35 +0,0 @@ -use actix_web::web::{Data, Json}; -use lemmy_api_utils::context::LemmyContext; -use lemmy_db_schema::{ - source::person_post_mention::{PersonPostMention, PersonPostMentionUpdateForm}, - traits::Crud, -}; -use lemmy_db_views_inbox_combined::api::MarkPersonPostMentionAsRead; -use lemmy_db_views_local_user::LocalUserView; -use lemmy_db_views_site::api::SuccessResponse; -use lemmy_utils::error::{LemmyErrorType, LemmyResult}; - -pub async fn mark_post_mention_as_read( - data: Json, - context: Data, - local_user_view: LocalUserView, -) -> LemmyResult> { - let person_post_mention_id = data.person_post_mention_id; - let read_person_post_mention = - PersonPostMention::read(&mut context.pool(), person_post_mention_id).await?; - - if local_user_view.person.id != read_person_post_mention.recipient_id { - Err(LemmyErrorType::CouldntUpdatePost)? - } - - let person_post_mention_id = read_person_post_mention.id; - let read = Some(data.read); - PersonPostMention::update( - &mut context.pool(), - person_post_mention_id, - &PersonPostMentionUpdateForm { read }, - ) - .await?; - - Ok(Json(SuccessResponse::default())) -} diff --git a/crates/api/api/src/local_user/notifications/mark_reply_read.rs b/crates/api/api/src/local_user/notifications/mark_reply_read.rs deleted file mode 100644 index d832f6f48..000000000 --- a/crates/api/api/src/local_user/notifications/mark_reply_read.rs +++ /dev/null @@ -1,35 +0,0 @@ -use actix_web::web::{Data, Json}; -use lemmy_api_utils::context::LemmyContext; -use lemmy_db_schema::{ - source::comment_reply::{CommentReply, CommentReplyUpdateForm}, - traits::Crud, -}; -use lemmy_db_views_inbox_combined::api::MarkCommentReplyAsRead; -use lemmy_db_views_local_user::LocalUserView; -use lemmy_db_views_site::api::SuccessResponse; -use lemmy_utils::error::{LemmyErrorType, LemmyResult}; - -pub async fn mark_reply_as_read( - data: Json, - context: Data, - local_user_view: LocalUserView, -) -> LemmyResult> { - let comment_reply_id = data.comment_reply_id; - let read_comment_reply = CommentReply::read(&mut context.pool(), comment_reply_id).await?; - - if local_user_view.person.id != read_comment_reply.recipient_id { - Err(LemmyErrorType::CouldntUpdateComment)? - } - - let comment_reply_id = read_comment_reply.id; - let read = Some(data.read); - - CommentReply::update( - &mut context.pool(), - comment_reply_id, - &CommentReplyUpdateForm { read }, - ) - .await?; - - Ok(Json(SuccessResponse::default())) -} diff --git a/crates/api/api/src/local_user/notifications/mod.rs b/crates/api/api/src/local_user/notifications/mod.rs index 9f2048d90..dd0b9e488 100644 --- a/crates/api/api/src/local_user/notifications/mod.rs +++ b/crates/api/api/src/local_user/notifications/mod.rs @@ -1,6 +1,4 @@ -pub mod list_inbox; +pub mod list; pub mod mark_all_read; -pub mod mark_comment_mention_read; -pub mod mark_post_mention_read; -pub mod mark_reply_read; +pub mod mark_notification_read; pub mod unread_count; diff --git a/crates/api/api/src/local_user/notifications/unread_count.rs b/crates/api/api/src/local_user/notifications/unread_count.rs index 6877f8406..44a933eaf 100644 --- a/crates/api/api/src/local_user/notifications/unread_count.rs +++ b/crates/api/api/src/local_user/notifications/unread_count.rs @@ -1,21 +1,17 @@ use actix_web::web::{Data, Json}; use lemmy_api_utils::context::LemmyContext; -use lemmy_db_views_inbox_combined::{api::GetUnreadCountResponse, InboxCombinedViewInternal}; use lemmy_db_views_local_user::LocalUserView; +use lemmy_db_views_notification::{api::GetUnreadCountResponse, NotificationView}; use lemmy_utils::error::LemmyResult; pub async fn unread_count( context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { - let person_id = local_user_view.person.id; - let local_instance_id = local_user_view.person.instance_id; let show_bot_accounts = local_user_view.local_user.show_bot_accounts; - - let count = InboxCombinedViewInternal::get_unread_count( + let count = NotificationView::get_unread_count( &mut context.pool(), - person_id, - local_instance_id, + &local_user_view.person, show_bot_accounts, ) .await?; diff --git a/crates/api/api/src/post/mod.rs b/crates/api/api/src/post/mod.rs index 97410f097..93f7feae7 100644 --- a/crates/api/api/src/post/mod.rs +++ b/crates/api/api/src/post/mod.rs @@ -7,3 +7,4 @@ pub mod lock; pub mod mark_many_read; pub mod mark_read; pub mod save; +pub mod update_notifications; diff --git a/crates/api/api/src/post/update_notifications.rs b/crates/api/api/src/post/update_notifications.rs new file mode 100644 index 000000000..7d2f7e74d --- /dev/null +++ b/crates/api/api/src/post/update_notifications.rs @@ -0,0 +1,23 @@ +use activitypub_federation::config::Data; +use actix_web::web::Json; +use lemmy_api_utils::context::LemmyContext; +use lemmy_db_schema::source::post::PostActions; +use lemmy_db_views_local_user::LocalUserView; +use lemmy_db_views_post::api::UpdatePostNotifications; +use lemmy_db_views_site::api::SuccessResponse; +use lemmy_utils::error::LemmyResult; + +pub async fn update_post_notifications( + data: Json, + context: Data, + local_user_view: LocalUserView, +) -> LemmyResult> { + PostActions::update_notification_state( + data.post_id, + local_user_view.person.id, + data.mode, + &mut context.pool(), + ) + .await?; + Ok(Json(SuccessResponse::default())) +} diff --git a/crates/api/api/src/private_message/mark_read.rs b/crates/api/api/src/private_message/mark_read.rs deleted file mode 100644 index 194b043c8..000000000 --- a/crates/api/api/src/private_message/mark_read.rs +++ /dev/null @@ -1,38 +0,0 @@ -use actix_web::web::{Data, Json}; -use lemmy_api_utils::context::LemmyContext; -use lemmy_db_schema::{ - source::private_message::{PrivateMessage, PrivateMessageUpdateForm}, - traits::Crud, -}; -use lemmy_db_views_inbox_combined::api::MarkPrivateMessageAsRead; -use lemmy_db_views_local_user::LocalUserView; -use lemmy_db_views_site::api::SuccessResponse; -use lemmy_utils::error::{LemmyErrorType, LemmyResult}; - -pub async fn mark_pm_as_read( - data: Json, - context: Data, - local_user_view: LocalUserView, -) -> LemmyResult> { - // Checking permissions - let private_message_id = data.private_message_id; - let orig_private_message = PrivateMessage::read(&mut context.pool(), private_message_id).await?; - if local_user_view.person.id != orig_private_message.recipient_id { - Err(LemmyErrorType::CouldntUpdatePrivateMessage)? - } - - // Doing the update - let private_message_id = data.private_message_id; - let read = data.read; - PrivateMessage::update( - &mut context.pool(), - private_message_id, - &PrivateMessageUpdateForm { - read: Some(read), - ..Default::default() - }, - ) - .await?; - - Ok(Json(SuccessResponse::default())) -} diff --git a/crates/api/api/src/private_message/mod.rs b/crates/api/api/src/private_message/mod.rs deleted file mode 100644 index f680957df..000000000 --- a/crates/api/api/src/private_message/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod mark_read; diff --git a/crates/api/api/src/site/registration_applications/tests.rs b/crates/api/api/src/site/registration_applications/tests.rs index 2a356682e..caa62bfa8 100644 --- a/crates/api/api/src/site/registration_applications/tests.rs +++ b/crates/api/api/src/site/registration_applications/tests.rs @@ -20,8 +20,8 @@ use lemmy_db_schema::{ utils::DbPool, }; use lemmy_db_schema_file::enums::RegistrationMode; -use lemmy_db_views_inbox_combined::api::GetUnreadRegistrationApplicationCountResponse; use lemmy_db_views_local_user::LocalUserView; +use lemmy_db_views_notification::api::GetUnreadRegistrationApplicationCountResponse; use lemmy_db_views_registration_applications::api::{ ApproveRegistrationApplication, ListRegistrationApplicationsResponse, diff --git a/crates/api/api/src/site/registration_applications/unread_count.rs b/crates/api/api/src/site/registration_applications/unread_count.rs index 6e97f4018..41ad0c12c 100644 --- a/crates/api/api/src/site/registration_applications/unread_count.rs +++ b/crates/api/api/src/site/registration_applications/unread_count.rs @@ -1,8 +1,8 @@ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{context::LemmyContext, utils::is_admin}; -use lemmy_db_views_inbox_combined::api::GetUnreadRegistrationApplicationCountResponse; use lemmy_db_views_local_user::LocalUserView; +use lemmy_db_views_notification::api::GetUnreadRegistrationApplicationCountResponse; use lemmy_db_views_registration_applications::RegistrationApplicationView; use lemmy_db_views_site::SiteView; use lemmy_utils::error::LemmyResult; diff --git a/crates/api/api_common/Cargo.toml b/crates/api/api_common/Cargo.toml index 9ca3511db..09f7ae968 100644 --- a/crates/api/api_common/Cargo.toml +++ b/crates/api/api_common/Cargo.toml @@ -27,7 +27,7 @@ ts-rs = [ "lemmy_db_views_community_follower/ts-rs", "lemmy_db_views_community_moderator/ts-rs", "lemmy_db_views_custom_emoji/ts-rs", - "lemmy_db_views_inbox_combined/ts-rs", + "lemmy_db_views_notification/ts-rs", "lemmy_db_views_local_image/ts-rs", "lemmy_db_views_local_user/ts-rs", "lemmy_db_views_modlog_combined/ts-rs", @@ -54,7 +54,7 @@ lemmy_db_views_community.workspace = true lemmy_db_views_community_follower.workspace = true lemmy_db_views_community_moderator.workspace = true lemmy_db_views_custom_emoji.workspace = true -lemmy_db_views_inbox_combined.workspace = true +lemmy_db_views_notification.workspace = true lemmy_db_views_local_image.workspace = true lemmy_db_views_local_user.workspace = true lemmy_db_views_modlog_combined.workspace = true diff --git a/crates/api/api_common/src/inbox.rs b/crates/api/api_common/src/inbox.rs deleted file mode 100644 index be3cd6bf8..000000000 --- a/crates/api/api_common/src/inbox.rs +++ /dev/null @@ -1,24 +0,0 @@ -pub use lemmy_db_schema::{ - newtypes::{CommentReplyId, PersonCommentMentionId, PersonPostMentionId}, - source::{ - comment_reply::CommentReply, - person_comment_mention::PersonCommentMention, - person_post_mention::PersonPostMention, - }, - InboxDataType, -}; -pub use lemmy_db_views_inbox_combined::{ - api::{ - GetUnreadCountResponse, - MarkCommentReplyAsRead, - MarkPersonCommentMentionAsRead, - MarkPersonPostMentionAsRead, - MarkPrivateMessageAsRead, - }, - CommentReplyView, - InboxCombinedView, - ListInbox, - ListInboxResponse, - PersonCommentMentionView, - PersonPostMentionView, -}; diff --git a/crates/api/api_common/src/lib.rs b/crates/api/api_common/src/lib.rs index 21c017a5a..a478e371d 100644 --- a/crates/api/api_common/src/lib.rs +++ b/crates/api/api_common/src/lib.rs @@ -4,10 +4,10 @@ pub mod community; pub mod custom_emoji; pub mod error; pub mod federation; -pub mod inbox; pub mod language; pub mod media; pub mod modlog; +pub mod notification; pub mod oauth; pub mod person; pub mod plugin; diff --git a/crates/api/api_common/src/notification.rs b/crates/api/api_common/src/notification.rs new file mode 100644 index 000000000..48212a3d1 --- /dev/null +++ b/crates/api/api_common/src/notification.rs @@ -0,0 +1,11 @@ +pub use lemmy_db_schema::{ + newtypes::NotificationId, + source::notification::Notification, + NotificationDataType, +}; +pub use lemmy_db_views_notification::{ + api::{GetUnreadCountResponse, MarkNotificationAsRead, MarkPrivateMessageAsRead}, + ListNotifications, + ListNotificationsResponse, + NotificationView, +}; diff --git a/crates/api/api_common/src/site.rs b/crates/api/api_common/src/site.rs index e1655fbe6..cc78a419f 100644 --- a/crates/api/api_common/src/site.rs +++ b/crates/api/api_common/src/site.rs @@ -14,8 +14,8 @@ pub use lemmy_db_views_site::{ }; pub mod administration { - pub use lemmy_db_views_inbox_combined::api::GetUnreadRegistrationApplicationCountResponse; pub use lemmy_db_views_local_user::api::{AdminListUsers, AdminListUsersResponse}; + pub use lemmy_db_views_notification::api::GetUnreadRegistrationApplicationCountResponse; pub use lemmy_db_views_person::api::{AddAdmin, AddAdminResponse}; pub use lemmy_db_views_registration_applications::api::{ ApproveRegistrationApplication, diff --git a/crates/api/api_crud/src/comment/create.rs b/crates/api/api_crud/src/comment/create.rs index 270243ec2..cd1b98140 100644 --- a/crates/api/api_crud/src/comment/create.rs +++ b/crates/api/api_crud/src/comment/create.rs @@ -2,8 +2,9 @@ use crate::community_use_pending; use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ - build_response::{build_comment_response, send_local_notifs}, + build_response::build_comment_response, context::LemmyContext, + notify::NotifyData, plugins::{plugin_hook_after, plugin_hook_before}, send_activity::{ActivityChannel, SendActivityData}, utils::{ @@ -19,11 +20,9 @@ use lemmy_api_utils::{ }; use lemmy_db_schema::{ impls::actor_language::validate_post_language, - newtypes::PostOrCommentId, source::{ comment::{Comment, CommentActions, CommentInsertForm, CommentLikeForm}, - comment_reply::{CommentReply, CommentReplyUpdateForm}, - person_comment_mention::{PersonCommentMention, PersonCommentMentionUpdateForm}, + notification::Notification, }, traits::{Crud, Likeable}, }; @@ -33,7 +32,7 @@ use lemmy_db_views_post::PostView; use lemmy_db_views_site::SiteView; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, - utils::{mention::scrape_text_for_mentions, validation::is_valid_body_field}, + utils::validation::is_valid_body_field, }; pub async fn create_comment( @@ -113,20 +112,14 @@ pub async fn create_comment( Comment::create(&mut context.pool(), &comment_form, parent_path.as_ref()).await?; plugin_hook_after("after_create_local_comment", &inserted_comment)?; - let inserted_comment_id = inserted_comment.id; - - // Scan the comment for user mentions, add those rows - let mentions = scrape_text_for_mentions(&content); - let do_send_email = !local_site.disable_email_notifications; - let recipient_ids = send_local_notifs( - mentions, - PostOrCommentId::Comment(inserted_comment_id), + NotifyData::new( + &post, + Some(&inserted_comment), &local_user_view.person, - do_send_email, - &context, - Some(&local_user_view), - local_instance_id, + &post_view.community, + !local_site.disable_email_notifications, ) + .send(&context) .await?; // You like your own comment by default @@ -153,30 +146,10 @@ pub async fn create_comment( // then mark the parent as read. // Then we don't have to do it manually after we respond to a comment. if let Some(parent) = parent_opt { - let person_id = local_user_view.person.id; - let parent_id = parent.id; - let comment_reply = - CommentReply::read_by_comment_and_person(&mut context.pool(), parent_id, person_id).await; - if let Ok(Some(reply)) = comment_reply { - CommentReply::update( - &mut context.pool(), - reply.id, - &CommentReplyUpdateForm { read: Some(true) }, - ) - .await?; - } - - // If the parent has PersonCommentMentions mark them as read too - let person_comment_mention = - PersonCommentMention::read_by_comment_and_person(&mut context.pool(), parent_id, person_id) - .await; - if let Ok(Some(mention)) = person_comment_mention { - PersonCommentMention::update( - &mut context.pool(), - mention.id, - &PersonCommentMentionUpdateForm { read: Some(true) }, - ) - .await?; + let notif = Notification::read_by_comment_id(&mut context.pool(), parent.id).await; + if let Ok(notif) = notif { + let person_id = local_user_view.person.id; + Notification::mark_read_by_id_and_person(&mut context.pool(), notif.id, person_id).await?; } } @@ -185,7 +158,6 @@ pub async fn create_comment( &context, inserted_comment.id, Some(local_user_view), - recipient_ids, local_instance_id, ) .await?, diff --git a/crates/api/api_crud/src/comment/delete.rs b/crates/api/api_crud/src/comment/delete.rs index 9362ce768..28b3f0a6b 100644 --- a/crates/api/api_crud/src/comment/delete.rs +++ b/crates/api/api_crud/src/comment/delete.rs @@ -1,13 +1,12 @@ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ - build_response::{build_comment_response, send_local_notifs}, + build_response::build_comment_response, context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::check_community_user_action, }; use lemmy_db_schema::{ - newtypes::PostOrCommentId, source::comment::{Comment, CommentUpdateForm}, traits::Crud, }; @@ -62,16 +61,6 @@ pub async fn delete_comment( ) .await?; - let recipient_ids = send_local_notifs( - vec![], - PostOrCommentId::Comment(comment_id), - &local_user_view.person, - false, - &context, - Some(&local_user_view), - local_instance_id, - ) - .await?; let updated_comment_id = updated_comment.id; ActivityChannel::submit_activity( @@ -88,7 +77,6 @@ pub async fn delete_comment( &context, updated_comment_id, Some(local_user_view), - recipient_ids, local_instance_id, ) .await?, diff --git a/crates/api/api_crud/src/comment/read.rs b/crates/api/api_crud/src/comment/read.rs index d4da80665..706e89318 100644 --- a/crates/api/api_crud/src/comment/read.rs +++ b/crates/api/api_crud/src/comment/read.rs @@ -21,13 +21,6 @@ pub async fn get_comment( check_private_instance(&local_user_view, &local_site)?; Ok(Json( - build_comment_response( - &context, - data.id, - local_user_view, - vec![], - local_instance_id, - ) - .await?, + build_comment_response(&context, data.id, local_user_view, local_instance_id).await?, )) } diff --git a/crates/api/api_crud/src/comment/remove.rs b/crates/api/api_crud/src/comment/remove.rs index 5fa627074..2b840b072 100644 --- a/crates/api/api_crud/src/comment/remove.rs +++ b/crates/api/api_crud/src/comment/remove.rs @@ -1,13 +1,12 @@ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ - build_response::{build_comment_response, send_local_notifs}, + build_response::build_comment_response, context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, utils::check_community_mod_action, }; use lemmy_db_schema::{ - newtypes::PostOrCommentId, source::{ comment::{Comment, CommentUpdateForm}, comment_report::CommentReport, @@ -84,16 +83,6 @@ pub async fn remove_comment( }; ModRemoveComment::create(&mut context.pool(), &form).await?; - let recipient_ids = send_local_notifs( - vec![], - PostOrCommentId::Comment(comment_id), - &local_user_view.person, - false, - &context, - Some(&local_user_view), - local_instance_id, - ) - .await?; let updated_comment_id = updated_comment.id; ActivityChannel::submit_activity( @@ -111,7 +100,6 @@ pub async fn remove_comment( &context, updated_comment_id, Some(local_user_view), - recipient_ids, local_instance_id, ) .await?, diff --git a/crates/api/api_crud/src/comment/update.rs b/crates/api/api_crud/src/comment/update.rs index ef7efef1f..19e2385ad 100644 --- a/crates/api/api_crud/src/comment/update.rs +++ b/crates/api/api_crud/src/comment/update.rs @@ -2,15 +2,15 @@ use activitypub_federation::config::Data; use actix_web::web::Json; use chrono::Utc; use lemmy_api_utils::{ - build_response::{build_comment_response, send_local_notifs}, + build_response::build_comment_response, context::LemmyContext, + notify::NotifyData, plugins::{plugin_hook_after, plugin_hook_before}, send_activity::{ActivityChannel, SendActivityData}, utils::{check_community_user_action, get_url_blocklist, process_markdown_opt, slur_regex}, }; use lemmy_db_schema::{ impls::actor_language::validate_post_language, - newtypes::PostOrCommentId, source::comment::{Comment, CommentUpdateForm}, traits::Crud, }; @@ -21,7 +21,7 @@ use lemmy_db_views_comment::{ use lemmy_db_views_local_user::LocalUserView; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, - utils::{mention::scrape_text_for_mentions, validation::is_valid_body_field}, + utils::validation::is_valid_body_field, }; pub async fn update_comment( @@ -79,17 +79,14 @@ pub async fn update_comment( plugin_hook_after("after_update_local_comment", &updated_comment)?; // Do the mentions / recipients - let updated_comment_content = updated_comment.content.clone(); - let mentions = scrape_text_for_mentions(&updated_comment_content); - let recipient_ids = send_local_notifs( - mentions, - PostOrCommentId::Comment(comment_id), + NotifyData::new( + &orig_comment.post, + Some(&updated_comment), &local_user_view.person, + &orig_comment.community, false, - &context, - Some(&local_user_view), - local_instance_id, ) + .send(&context) .await?; ActivityChannel::submit_activity( @@ -102,7 +99,6 @@ pub async fn update_comment( &context, updated_comment.id, Some(local_user_view), - recipient_ids, local_instance_id, ) .await?, diff --git a/crates/api/api_crud/src/post/create.rs b/crates/api/api_crud/src/post/create.rs index 2fbe9a6dc..ff0738405 100644 --- a/crates/api/api_crud/src/post/create.rs +++ b/crates/api/api_crud/src/post/create.rs @@ -3,8 +3,9 @@ use crate::community_use_pending; use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ - build_response::{build_post_response, send_local_notifs}, + build_response::build_post_response, context::LemmyContext, + notify::NotifyData, plugins::{plugin_hook_after, plugin_hook_before}, request::generate_post_link_metadata, send_activity::SendActivityData, @@ -21,7 +22,6 @@ use lemmy_api_utils::{ }; use lemmy_db_schema::{ impls::actor_language::validate_post_language, - newtypes::PostOrCommentId, source::post::{Post, PostActions, PostInsertForm, PostLikeForm, PostReadForm}, traits::{Crud, Likeable}, utils::diesel_url_create, @@ -34,7 +34,6 @@ use lemmy_db_views_site::SiteView; use lemmy_utils::{ error::LemmyResult, utils::{ - mention::scrape_text_for_mentions, slurs::check_slurs, validation::{ is_url_blocked, @@ -169,23 +168,18 @@ pub async fn create_post( // They like their own post by default let person_id = local_user_view.person.id; let post_id = inserted_post.id; - let local_instance_id = local_user_view.person.instance_id; let like_form = PostLikeForm::new(post_id, person_id, 1); PostActions::like(&mut context.pool(), &like_form).await?; - // Scan the post body for user mentions, add those rows - let mentions = scrape_text_for_mentions(&inserted_post.body.clone().unwrap_or_default()); - let do_send_email = !local_site.disable_email_notifications; - send_local_notifs( - mentions, - PostOrCommentId::Post(inserted_post.id), + NotifyData::new( + &inserted_post, + None, &local_user_view.person, - do_send_email, - &context, - Some(&local_user_view), - local_instance_id, + community, + !local_site.disable_email_notifications, ) + .send(&context) .await?; let read_form = PostReadForm::new(post_id, person_id); diff --git a/crates/api/api_crud/src/post/update.rs b/crates/api/api_crud/src/post/update.rs index ea5aa7a04..b02199778 100644 --- a/crates/api/api_crud/src/post/update.rs +++ b/crates/api/api_crud/src/post/update.rs @@ -3,8 +3,9 @@ use activitypub_federation::config::Data; use actix_web::web::Json; use chrono::Utc; use lemmy_api_utils::{ - build_response::{build_post_response, send_local_notifs}, + build_response::build_post_response, context::LemmyContext, + notify::NotifyData, plugins::{plugin_hook_after, plugin_hook_before}, request::generate_post_link_metadata, send_activity::SendActivityData, @@ -20,7 +21,6 @@ use lemmy_api_utils::{ }; use lemmy_db_schema::{ impls::actor_language::validate_post_language, - newtypes::PostOrCommentId, source::{ community::Community, post::{Post, PostUpdateForm}, @@ -38,7 +38,6 @@ use lemmy_db_views_site::SiteView; use lemmy_utils::{ error::{LemmyErrorType, LemmyResult}, utils::{ - mention::scrape_text_for_mentions, slurs::check_slurs, validation::{ is_url_blocked, @@ -163,17 +162,14 @@ pub async fn update_post( let updated_post = Post::update(&mut context.pool(), post_id, &post_form).await?; plugin_hook_after("after_update_local_post", &post_form)?; - // Scan the post body for user mentions, add those rows - let mentions = scrape_text_for_mentions(&updated_post.body.clone().unwrap_or_default()); - send_local_notifs( - mentions, - PostOrCommentId::Post(updated_post.id), + NotifyData::new( + &updated_post, + None, &local_user_view.person, + &orig_post.community, false, - &context, - Some(&local_user_view), - local_instance_id, ) + .send(&context) .await?; // send out federation/webmention if necessary diff --git a/crates/api/api_crud/src/private_message/create.rs b/crates/api/api_crud/src/private_message/create.rs index e55904803..3540e7084 100644 --- a/crates/api/api_crud/src/private_message/create.rs +++ b/crates/api/api_crud/src/private_message/create.rs @@ -2,6 +2,7 @@ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_utils::{ context::LemmyContext, + notify::notify_private_message, plugins::{plugin_hook_after, plugin_hook_before}, send_activity::{ActivityChannel, SendActivityData}, utils::{check_private_messages_enabled, get_url_blocklist, process_markdown, slur_regex}, @@ -18,7 +19,6 @@ use lemmy_db_views_private_message::{ api::{CreatePrivateMessage, PrivateMessageResponse}, PrivateMessageView, }; -use lemmy_email::notifications::send_private_message_email; use lemmy_utils::{error::LemmyResult, utils::validation::is_valid_body_field}; pub async fn create_private_message( @@ -63,18 +63,7 @@ pub async fn create_private_message( let view = PrivateMessageView::read(&mut context.pool(), inserted_private_message.id).await?; - // Send email to the local recipient, if one exists - if view.recipient.local { - let local_recipient = - LocalUserView::read_person(&mut context.pool(), data.recipient_id).await?; - send_private_message_email( - &local_user_view, - &local_recipient, - &content, - context.settings(), - ) - .await; - } + notify_private_message(&view, true, &context).await?; ActivityChannel::submit_activity( SendActivityData::CreatePrivateMessage(view.clone()), diff --git a/crates/api/api_crud/src/private_message/update.rs b/crates/api/api_crud/src/private_message/update.rs index bf6f836b6..9bb4b199e 100644 --- a/crates/api/api_crud/src/private_message/update.rs +++ b/crates/api/api_crud/src/private_message/update.rs @@ -3,6 +3,7 @@ use actix_web::web::Json; use chrono::Utc; use lemmy_api_utils::{ context::LemmyContext, + notify::notify_private_message, plugins::{plugin_hook_after, plugin_hook_before}, send_activity::{ActivityChannel, SendActivityData}, utils::{get_url_blocklist, process_markdown, slur_regex}, @@ -52,6 +53,8 @@ pub async fn update_private_message( let view = PrivateMessageView::read(&mut context.pool(), private_message_id).await?; + notify_private_message(&view, false, &context).await?; + ActivityChannel::submit_activity( SendActivityData::UpdatePrivateMessage(view.clone()), &context, diff --git a/crates/api/api_utils/Cargo.toml b/crates/api/api_utils/Cargo.toml index a4ba473ec..63544cae7 100644 --- a/crates/api/api_utils/Cargo.toml +++ b/crates/api/api_utils/Cargo.toml @@ -77,8 +77,10 @@ webpage = { version = "2.0", default-features = false, features = ["serde"] } regex = { workspace = true } jsonwebtoken = { version = "9.3.1" } either.workspace = true +derive-new.workspace = true [dev-dependencies] serial_test = { workspace = true } pretty_assertions = { workspace = true } +lemmy_db_views_notification = { workspace = true, features = ["full"] } diesel_ltree = { workspace = true } diff --git a/crates/api/api_utils/src/build_response.rs b/crates/api/api_utils/src/build_response.rs index 67ca60534..ce5c1ed16 100644 --- a/crates/api/api_utils/src/build_response.rs +++ b/crates/api/api_utils/src/build_response.rs @@ -1,38 +1,19 @@ -use crate::{ - context::LemmyContext, - utils::{check_person_instance_community_block, is_mod_or_admin}, -}; +use crate::{context::LemmyContext, utils::is_mod_or_admin}; use actix_web::web::Json; use lemmy_db_schema::{ - newtypes::{CommentId, CommunityId, InstanceId, LocalUserId, PostId, PostOrCommentId}, - source::{ - actor_language::CommunityLanguage, - comment::Comment, - comment_reply::{CommentReply, CommentReplyInsertForm}, - community::Community, - person::Person, - person_comment_mention::{PersonCommentMention, PersonCommentMentionInsertForm}, - person_post_mention::{PersonPostMention, PersonPostMentionInsertForm}, - post::Post, - }, - traits::Crud, + newtypes::{CommentId, CommunityId, InstanceId, PostId}, + source::actor_language::CommunityLanguage, }; use lemmy_db_views_comment::{api::CommentResponse, CommentView}; use lemmy_db_views_community::{api::CommunityResponse, CommunityView}; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::{api::PostResponse, PostView}; -use lemmy_email::notifications::{ - send_comment_reply_email, - send_mention_email, - send_post_reply_email, -}; -use lemmy_utils::{error::LemmyResult, utils::mention::MentionData}; +use lemmy_utils::error::LemmyResult; pub async fn build_comment_response( context: &LemmyContext, comment_id: CommentId, local_user_view: Option, - recipient_ids: Vec, local_instance_id: InstanceId, ) -> LemmyResult { let local_user = local_user_view.map(|l| l.local_user); @@ -43,10 +24,7 @@ pub async fn build_comment_response( local_instance_id, ) .await?; - Ok(CommentResponse { - comment_view, - recipient_ids, - }) + Ok(CommentResponse { comment_view }) } pub async fn build_community_response( @@ -93,221 +71,3 @@ pub async fn build_post_response( .await?; Ok(Json(PostResponse { post_view })) } - -// TODO: this function is a mess and should be split up to handle email separately -pub async fn send_local_notifs( - mentions: Vec, - post_or_comment_id: PostOrCommentId, - person: &Person, - do_send_email: bool, - context: &LemmyContext, - local_user_view: Option<&LocalUserView>, - local_instance_id: InstanceId, -) -> LemmyResult> { - let mut recipient_ids = Vec::new(); - - let (comment_opt, post, community) = match post_or_comment_id { - PostOrCommentId::Post(post_id) => { - let post_view = PostView::read( - &mut context.pool(), - post_id, - local_user_view.map(|view| &view.local_user), - local_instance_id, - false, - ) - .await?; - (None, post_view.post, post_view.community) - } - PostOrCommentId::Comment(comment_id) => { - // When called from api code, we have local user view and can read with CommentView - // to reduce db queries. But when receiving a federated comment the user view is None, - // which means that comments inside private communities cant be read. As a workaround - // we need to read the items manually to bypass this check. - if let Some(local_user_view) = local_user_view { - // Read the comment view to get extra info - let comment_view = CommentView::read( - &mut context.pool(), - comment_id, - Some(&local_user_view.local_user), - local_instance_id, - ) - .await?; - ( - Some(comment_view.comment), - comment_view.post, - comment_view.community, - ) - } else { - let comment = Comment::read(&mut context.pool(), comment_id).await?; - let post = Post::read(&mut context.pool(), comment.post_id).await?; - let community = Community::read(&mut context.pool(), post.community_id).await?; - (Some(comment), post, community) - } - } - }; - - // Send the local mentions - for mention in mentions - .iter() - .filter(|m| m.is_local(&context.settings().hostname) && m.name.ne(&person.name)) - { - let mention_name = mention.name.clone(); - let user_view = LocalUserView::read_from_name(&mut context.pool(), &mention_name).await; - if let Ok(mention_user_view) = user_view { - // TODO - // At some point, make it so you can't tag the parent creator either - // Potential duplication of notifications, one for reply and the other for mention, is handled - // below by checking recipient ids - recipient_ids.push(mention_user_view.local_user.id); - - // Make the correct reply form depending on whether its a post or comment mention - let (link, comment_content_or_post_body) = if let Some(comment) = &comment_opt { - let person_comment_mention_form = PersonCommentMentionInsertForm { - recipient_id: mention_user_view.person.id, - comment_id: comment.id, - read: None, - }; - - // Allow this to fail softly, since comment edits might re-update or replace it - // Let the uniqueness handle this fail - PersonCommentMention::create(&mut context.pool(), &person_comment_mention_form) - .await - .ok(); - ( - comment.local_url(context.settings())?, - comment.content.clone(), - ) - } else { - let person_post_mention_form = PersonPostMentionInsertForm { - recipient_id: mention_user_view.person.id, - post_id: post.id, - read: None, - }; - - // Allow this to fail softly, since edits might re-update or replace it - PersonPostMention::create(&mut context.pool(), &person_post_mention_form) - .await - .ok(); - ( - post.local_url(context.settings())?, - post.body.clone().unwrap_or_default(), - ) - }; - - // Send an email to those local users that have notifications on - if do_send_email { - send_mention_email( - &mention_user_view, - &comment_content_or_post_body, - person, - link.into(), - context.settings(), - ) - .await; - } - } - } - - // Send comment_reply to the parent commenter / poster - if let Some(comment) = &comment_opt { - if let Some(parent_comment_id) = comment.parent_comment_id() { - let parent_comment = Comment::read(&mut context.pool(), parent_comment_id).await?; - - // Get the parent commenter local_user - let parent_creator_id = parent_comment.creator_id; - - let check_blocks = check_person_instance_community_block( - person.id, - parent_creator_id, - // Only block from the community's instance_id - community.instance_id, - community.id, - &mut context.pool(), - ) - .await - .is_err(); - - // Don't send a notif to yourself - if parent_comment.creator_id != person.id && !check_blocks { - let user_view = LocalUserView::read_person(&mut context.pool(), parent_creator_id).await; - if let Ok(parent_user_view) = user_view { - // Don't duplicate notif if already mentioned by checking recipient ids - if !recipient_ids.contains(&parent_user_view.local_user.id) { - recipient_ids.push(parent_user_view.local_user.id); - - let comment_reply_form = CommentReplyInsertForm { - recipient_id: parent_user_view.person.id, - comment_id: comment.id, - read: None, - }; - - // Allow this to fail softly, since comment edits might re-update or replace it - // Let the uniqueness handle this fail - CommentReply::create(&mut context.pool(), &comment_reply_form) - .await - .ok(); - - if do_send_email { - send_comment_reply_email( - &parent_user_view, - comment, - person, - &parent_comment, - &post, - context.settings(), - ) - .await?; - } - } - } - } - } else { - // Use the post creator to check blocks - let check_blocks = check_person_instance_community_block( - person.id, - post.creator_id, - // Only block from the community's instance_id - community.instance_id, - community.id, - &mut context.pool(), - ) - .await - .is_err(); - - if post.creator_id != person.id && !check_blocks { - let creator_id = post.creator_id; - let parent_user = LocalUserView::read_person(&mut context.pool(), creator_id).await; - if let Ok(parent_user_view) = parent_user { - if !recipient_ids.contains(&parent_user_view.local_user.id) { - recipient_ids.push(parent_user_view.local_user.id); - - let comment_reply_form = CommentReplyInsertForm { - recipient_id: parent_user_view.person.id, - comment_id: comment.id, - read: None, - }; - - // Allow this to fail softly, since comment edits might re-update or replace it - // Let the uniqueness handle this fail - CommentReply::create(&mut context.pool(), &comment_reply_form) - .await - .ok(); - - if do_send_email { - send_post_reply_email( - &parent_user_view, - comment, - person, - &post, - context.settings(), - ) - .await?; - } - } - } - } - } - } - - Ok(recipient_ids) -} diff --git a/crates/api/api_utils/src/lib.rs b/crates/api/api_utils/src/lib.rs index 961ad7e24..4c34d2f5f 100644 --- a/crates/api/api_utils/src/lib.rs +++ b/crates/api/api_utils/src/lib.rs @@ -1,6 +1,7 @@ pub mod build_response; pub mod claims; pub mod context; +pub mod notify; pub mod plugins; pub mod request; pub mod send_activity; diff --git a/crates/api/api_utils/src/notify.rs b/crates/api/api_utils/src/notify.rs new file mode 100644 index 000000000..237c6681a --- /dev/null +++ b/crates/api/api_utils/src/notify.rs @@ -0,0 +1,765 @@ +use crate::context::LemmyContext; +use lemmy_db_schema::{ + newtypes::PersonId, + source::{ + comment::Comment, + community::{Community, CommunityActions}, + instance::InstanceActions, + notification::{Notification, NotificationInsertForm}, + person::{Person, PersonActions}, + post::{Post, PostActions}, + }, + traits::{Blockable, Crud}, +}; +use lemmy_db_schema_file::enums::{ + CommunityNotificationsMode, + NotificationTypes, + PostNotificationsMode, +}; +use lemmy_db_views_local_user::LocalUserView; +use lemmy_db_views_private_message::PrivateMessageView; +use lemmy_db_views_site::SiteView; +use lemmy_email::notifications::{ + send_community_subscribed_email, + send_mention_email, + send_post_subscribed_email, + send_private_message_email, + send_reply_email, +}; +use lemmy_utils::{ + error::{LemmyErrorType, LemmyResult}, + utils::mention::scrape_text_for_mentions, +}; +use url::Url; + +#[derive(derive_new::new, Debug)] +pub struct NotifyData<'a> { + post: &'a Post, + comment_opt: Option<&'a Comment>, + creator: &'a Person, + community: &'a Community, + do_send_email: bool, +} + +impl NotifyData<'_> { + /// Scans the post/comment content for mentions, then sends notifications via db and email + /// to mentioned users and parent creator. + pub async fn send(self, context: &LemmyContext) -> LemmyResult<()> { + notify_parent_creator(&self, context).await?; + + notify_mentions(&self, context).await?; + + notify_subscribers(&self, context).await?; + + Ok(()) + } + + async fn check_notifications_allowed( + &self, + potential_blocker_id: PersonId, + context: &LemmyContext, + ) -> LemmyResult<()> { + let pool = &mut context.pool(); + // TODO: this needs too many queries for each user + PersonActions::read_block(pool, potential_blocker_id, self.post.creator_id).await?; + InstanceActions::read_block(pool, potential_blocker_id, self.community.instance_id).await?; + CommunityActions::read_block(pool, potential_blocker_id, self.post.community_id).await?; + let post_notifications = PostActions::read(pool, self.post.id, potential_blocker_id) + .await + .ok() + .and_then(|a| a.notifications) + .unwrap_or_default(); + let community_notifications = + CommunityActions::read(pool, self.community.id, potential_blocker_id) + .await + .ok() + .and_then(|a| a.notifications) + .unwrap_or_default(); + if post_notifications == PostNotificationsMode::Mute + || community_notifications == CommunityNotificationsMode::Mute + { + // The specific error type is irrelevant + return Err(LemmyErrorType::NotFound.into()); + } + + Ok(()) + } + + fn content(&self) -> String { + if let Some(comment) = self.comment_opt.as_ref() { + comment.content.clone() + } else { + self.post.body.clone().unwrap_or_default() + } + } + + fn link(&self, context: &LemmyContext) -> LemmyResult { + if let Some(comment) = self.comment_opt.as_ref() { + Ok(comment.local_url(context.settings())?) + } else { + Ok(self.post.local_url(context.settings())?) + } + } + + async fn insert( + &self, + recipient_id: PersonId, + kind: NotificationTypes, + context: &LemmyContext, + ) -> LemmyResult { + let form = if let Some(comment) = self.comment_opt { + NotificationInsertForm::new_comment(comment.id, recipient_id, kind) + } else { + NotificationInsertForm::new_post(self.post.id, recipient_id, kind) + }; + Notification::create(&mut context.pool(), &form).await + } +} + +pub async fn notify_private_message( + view: &PrivateMessageView, + is_create: bool, + context: &LemmyContext, +) -> LemmyResult<()> { + let Ok(local_recipient) = + LocalUserView::read_person(&mut context.pool(), view.recipient.id).await + else { + return Ok(()); + }; + + let form = NotificationInsertForm::new_private_message( + view.private_message.id, + local_recipient.person.id, + NotificationTypes::PrivateMessage, + ); + Notification::create(&mut context.pool(), &form).await?; + + if is_create { + let site_view = SiteView::read_local(&mut context.pool()).await?; + if !site_view.local_site.disable_email_notifications { + send_private_message_email( + &view.creator, + &local_recipient, + &view.private_message.content, + context.settings(), + ) + .await; + } + } + Ok(()) +} + +async fn notify_parent_creator(data: &NotifyData<'_>, context: &LemmyContext) -> LemmyResult<()> { + let Some(comment) = data.comment_opt.as_ref() else { + return Ok(()); + }; + + // Get the parent data + let (parent_creator_id, parent_comment) = + if let Some(parent_comment_id) = comment.parent_comment_id() { + let parent_comment = Comment::read(&mut context.pool(), parent_comment_id).await?; + (parent_comment.creator_id, Some(parent_comment)) + } else { + (data.post.creator_id, None) + }; + + // Dont send notification to yourself + if parent_creator_id == data.creator.id { + return Ok(()); + } + + let is_blocked = data + .check_notifications_allowed(parent_creator_id, context) + .await + .is_err(); + if is_blocked { + return Ok(()); + } + + let Ok(user_view) = LocalUserView::read_person(&mut context.pool(), parent_creator_id).await + else { + return Ok(()); + }; + + data + .insert(user_view.person.id, NotificationTypes::Reply, context) + .await?; + + if data.do_send_email { + send_reply_email( + &user_view, + comment, + data.creator, + &parent_comment, + data.post, + context.settings(), + ) + .await?; + } + Ok(()) +} + +async fn notify_mentions(data: &NotifyData<'_>, context: &LemmyContext) -> LemmyResult<()> { + let mentions = scrape_text_for_mentions(&data.content()) + .into_iter() + .filter(|m| m.is_local(&context.settings().hostname) && m.name.ne(&data.creator.name)); + for mention in mentions { + // Ignore error if user is remote + let Ok(user_view) = LocalUserView::read_from_name(&mut context.pool(), &mention.name).await + else { + continue; + }; + + let is_blocked = data + .check_notifications_allowed(user_view.person.id, context) + .await + .is_err(); + if is_blocked { + continue; + }; + + data + .insert(user_view.person.id, NotificationTypes::Mention, context) + .await?; + + // Send an email to those local users that have notifications on + if data.do_send_email { + send_mention_email( + &user_view, + &data.content(), + data.creator, + data.link(context)?.into(), + context.settings(), + ) + .await; + } + } + Ok(()) +} + +async fn notify_subscribers(data: &NotifyData<'_>, context: &LemmyContext) -> LemmyResult<()> { + let is_post = data.comment_opt.is_none(); + let subscribers = vec![ + PostActions::list_subscribers(data.post.id, &mut context.pool()).await?, + CommunityActions::list_subscribers(data.post.community_id, is_post, &mut context.pool()) + .await?, + ] + .into_iter() + .flatten() + .collect::>(); + + for person_id in subscribers { + if data + .check_notifications_allowed(person_id, context) + .await + .is_err() + { + continue; + }; + + data + .insert(person_id, NotificationTypes::Subscribed, context) + .await?; + + if data.do_send_email { + let user_view = LocalUserView::read_person(&mut context.pool(), person_id).await?; + if let Some(comment) = data.comment_opt { + send_post_subscribed_email( + &user_view, + data.post, + comment, + data.link(context)?.into(), + context.settings(), + ) + .await; + } else { + send_community_subscribed_email( + &user_view, + data.post, + data.community, + data.link(context)?.into(), + context.settings(), + ) + .await; + } + } + } + + Ok(()) +} + +#[cfg(test)] +#[expect(clippy::indexing_slicing)] +mod tests { + use crate::{ + context::LemmyContext, + notify::{notify_private_message, NotifyData}, + }; + use lemmy_db_schema::{ + assert_length, + source::{ + comment::{Comment, CommentInsertForm}, + community::{Community, CommunityInsertForm}, + instance::{Instance, InstanceActions, InstanceBlockForm}, + notification::{Notification, NotificationInsertForm}, + person::{Person, PersonActions, PersonBlockForm, PersonInsertForm, PersonUpdateForm}, + post::{Post, PostInsertForm}, + private_message::{PrivateMessage, PrivateMessageInsertForm}, + }, + traits::{Blockable, Crud}, + utils::{build_db_pool_for_tests, DbPool}, + NotificationDataType, + }; + use lemmy_db_schema_file::enums::NotificationTypes; + use lemmy_db_views_local_user::LocalUserView; + use lemmy_db_views_notification::{impls::NotificationQuery, NotificationData, NotificationView}; + use lemmy_db_views_private_message::PrivateMessageView; + use lemmy_utils::error::LemmyResult; + use pretty_assertions::assert_eq; + use serial_test::serial; + + struct Data { + instance: Instance, + timmy: LocalUserView, + sara: LocalUserView, + jessica: Person, + community: Community, + timmy_post: Post, + jessica_post: Post, + timmy_comment: Comment, + sara_comment: Comment, + } + + async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { + let instance = Instance::read_or_create(pool, "example.com".to_string()).await?; + + let timmy = LocalUserView::create_test_user(pool, "timmy_pcv", "", false).await?; + + let sara = LocalUserView::create_test_user(pool, "sara_pcv", "", false).await?; + + let jessica_form = PersonInsertForm::test_form(instance.id, "jessica_mrv"); + let jessica = Person::create(pool, &jessica_form).await?; + + let community_form = CommunityInsertForm::new( + instance.id, + "test community pcv".to_string(), + "nada".to_owned(), + "pubkey".to_string(), + ); + let community = Community::create(pool, &community_form).await?; + + let timmy_post_form = + PostInsertForm::new("timmy post prv".into(), timmy.person.id, community.id); + let timmy_post = Post::create(pool, &timmy_post_form).await?; + + let jessica_post_form = + PostInsertForm::new("jessica post prv".into(), jessica.id, community.id); + let jessica_post = Post::create(pool, &jessica_post_form).await?; + + let timmy_comment_form = + CommentInsertForm::new(timmy.person.id, timmy_post.id, "timmy comment prv".into()); + let timmy_comment = Comment::create(pool, &timmy_comment_form, None).await?; + + let sara_comment_form = + CommentInsertForm::new(sara.person.id, timmy_post.id, "sara comment prv".into()); + let sara_comment = Comment::create(pool, &sara_comment_form, Some(&timmy_comment.path)).await?; + + Ok(Data { + instance, + timmy, + sara, + jessica, + community, + timmy_post, + jessica_post, + timmy_comment, + sara_comment, + }) + } + + async fn insert_private_message( + form: PrivateMessageInsertForm, + context: &LemmyContext, + ) -> LemmyResult<()> { + let pool = &mut context.pool(); + let pm = PrivateMessage::create(pool, &form).await?; + let view = PrivateMessageView::read(pool, pm.id).await?; + notify_private_message(&view, false, context).await?; + Ok(()) + } + async fn setup_private_messages(data: &Data, context: &LemmyContext) -> LemmyResult<()> { + let sara_timmy_message_form = PrivateMessageInsertForm::new( + data.sara.person.id, + data.timmy.person.id, + "sara to timmy".into(), + ); + insert_private_message(sara_timmy_message_form, context).await?; + + let sara_jessica_message_form = PrivateMessageInsertForm::new( + data.sara.person.id, + data.jessica.id, + "sara to jessica".into(), + ); + insert_private_message(sara_jessica_message_form, context).await?; + + let timmy_sara_message_form = PrivateMessageInsertForm::new( + data.timmy.person.id, + data.sara.person.id, + "timmy to sara".into(), + ); + insert_private_message(timmy_sara_message_form, context).await?; + + let jessica_timmy_message_form = PrivateMessageInsertForm::new( + data.jessica.id, + data.timmy.person.id, + "jessica to timmy".into(), + ); + insert_private_message(jessica_timmy_message_form, context).await?; + + Ok(()) + } + + async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { + Instance::delete(pool, data.instance.id).await?; + + Ok(()) + } + + #[tokio::test] + #[serial] + async fn replies() -> LemmyResult<()> { + let context = LemmyContext::init_test_context().await; + let pool = &mut context.pool(); + let data = init_data(pool).await?; + + // Sara replied to timmys comment + NotifyData { + post: &data.timmy_post, + comment_opt: Some(&data.sara_comment), + creator: &data.sara.person, + community: &data.community, + do_send_email: false, + } + .send(&context) + .await?; + + let timmy_unread_replies = + NotificationView::get_unread_count(pool, &data.timmy.person, true).await?; + assert_eq!(1, timmy_unread_replies); + + let timmy_inbox = NotificationQuery::default() + .list(pool, &data.timmy.person) + .await?; + assert_length!(1, timmy_inbox); + + if let NotificationData::Comment(comment) = &timmy_inbox[0].data { + assert_eq!(data.sara_comment.id, comment.comment.id); + assert_eq!(data.timmy_post.id, comment.post.id); + assert_eq!(data.sara.person.id, comment.creator.id); + assert_eq!( + data.timmy.person.id, + timmy_inbox[0].notification.recipient_id + ); + assert_eq!(NotificationTypes::Reply, timmy_inbox[0].notification.kind); + } else { + panic!("wrong type") + }; + + // Mark it as read + Notification::mark_read_by_id_and_person( + pool, + timmy_inbox[0].notification.id, + data.timmy.person.id, + ) + .await?; + + let timmy_unread_replies = + NotificationView::get_unread_count(pool, &data.timmy.person, true).await?; + assert_eq!(0, timmy_unread_replies); + + let timmy_inbox_unread = NotificationQuery { + unread_only: Some(true), + ..Default::default() + } + .list(pool, &data.timmy.person) + .await?; + assert_length!(0, timmy_inbox_unread); + + cleanup(data, pool).await?; + + Ok(()) + } + + #[tokio::test] + #[serial] + async fn mentions() -> LemmyResult<()> { + let pool = &build_db_pool_for_tests(); + let pool = &mut pool.into(); + let data = init_data(pool).await?; + + // Timmy mentions sara in a comment + let timmy_mention_sara_comment_form = NotificationInsertForm::new_comment( + data.timmy_comment.id, + data.sara.person.id, + NotificationTypes::Mention, + ); + Notification::create(pool, &timmy_mention_sara_comment_form).await?; + + // Jessica mentions sara in a post + let jessica_mention_sara_post_form = NotificationInsertForm::new_post( + data.jessica_post.id, + data.sara.person.id, + NotificationTypes::Mention, + ); + Notification::create(pool, &jessica_mention_sara_post_form).await?; + + // Test to make sure counts and blocks work correctly + let sara_unread_mentions = + NotificationView::get_unread_count(pool, &data.sara.person, true).await?; + assert_eq!(2, sara_unread_mentions); + + let sara_inbox = NotificationQuery::default() + .list(pool, &data.sara.person) + .await?; + assert_length!(2, sara_inbox); + + if let NotificationData::Post(post) = &sara_inbox[0].data { + assert_eq!(data.jessica_post.id, post.post.id); + assert_eq!(data.jessica.id, post.creator.id); + } else { + panic!("wrong type") + } + assert_eq!(data.sara.person.id, sara_inbox[0].notification.recipient_id); + assert_eq!(NotificationTypes::Mention, sara_inbox[0].notification.kind); + + if let NotificationData::Comment(comment) = &sara_inbox[1].data { + assert_eq!(data.timmy_comment.id, comment.comment.id); + assert_eq!(data.timmy_post.id, comment.post.id); + assert_eq!(data.timmy.person.id, comment.creator.id); + } else { + panic!("wrong type"); + } + assert_eq!(data.sara.person.id, sara_inbox[1].notification.recipient_id); + assert_eq!(NotificationTypes::Mention, sara_inbox[1].notification.kind); + + // Sara blocks timmy, and make sure these counts are now empty + let sara_blocks_timmy_form = PersonBlockForm::new(data.sara.person.id, data.timmy.person.id); + PersonActions::block(pool, &sara_blocks_timmy_form).await?; + + let sara_unread_mentions_after_block = + NotificationView::get_unread_count(pool, &data.sara.person, true).await?; + assert_eq!(1, sara_unread_mentions_after_block); + + let sara_inbox_after_block = NotificationQuery::default() + .list(pool, &data.sara.person) + .await?; + assert_length!(1, sara_inbox_after_block); + + // Make sure the comment mention which timmy made is the hidden one + assert_eq!( + NotificationTypes::Mention, + sara_inbox_after_block[0].notification.kind + ); + + // Unblock user so we can reuse the same person + PersonActions::unblock(pool, &sara_blocks_timmy_form).await?; + + // Test the type filter + let sara_inbox_mentions_only = NotificationQuery { + type_: Some(NotificationDataType::Mention), + ..Default::default() + } + .list(pool, &data.sara.person) + .await?; + assert_length!(2, sara_inbox_mentions_only); + + assert_eq!( + NotificationTypes::Mention, + sara_inbox_mentions_only[0].notification.kind + ); + + // Turn Jessica into a bot account + let person_update_form = PersonUpdateForm { + bot_account: Some(true), + ..Default::default() + }; + Person::update(pool, data.jessica.id, &person_update_form).await?; + + // Make sure sara hides bot + let sara_unread_mentions_after_hide_bots = + NotificationView::get_unread_count(pool, &data.sara.person, false).await?; + assert_eq!(1, sara_unread_mentions_after_hide_bots); + + let sara_inbox_after_hide_bots = NotificationQuery::default() + .list(pool, &data.sara.person) + .await?; + assert_length!(1, sara_inbox_after_hide_bots); + + // Make sure the post mention which jessica made is the hidden one + assert_eq!( + NotificationTypes::Mention, + sara_inbox_after_hide_bots[0].notification.kind + ); + + // Mark them all as read + Notification::mark_all_as_read(pool, data.sara.person.id).await?; + + // Make sure none come back + let sara_unread_mentions = + NotificationView::get_unread_count(pool, &data.sara.person, true).await?; + assert_eq!(0, sara_unread_mentions); + + let sara_inbox_unread = NotificationQuery { + unread_only: Some(true), + ..Default::default() + } + .list(pool, &data.sara.person) + .await?; + assert_length!(0, sara_inbox_unread); + + cleanup(data, pool).await?; + + Ok(()) + } + + /// Useful in combination with filter_map + fn to_pm(x: NotificationView) -> Option { + if let NotificationData::PrivateMessage(v) = x.data { + Some(v) + } else { + None + } + } + + #[tokio::test] + #[serial] + async fn read_private_messages() -> LemmyResult<()> { + let context = LemmyContext::init_test_context().await; + let pool = &mut context.pool(); + let data = init_data(pool).await?; + setup_private_messages(&data, &context).await?; + + let timmy_messages: Vec<_> = NotificationQuery::default() + .list(pool, &data.timmy.person) + .await? + .into_iter() + .filter_map(to_pm) + .collect(); + + // The read even shows timmy's sent messages + assert_length!(3, &timmy_messages); + assert_eq!(timmy_messages[0].creator.id, data.jessica.id); + assert_eq!(timmy_messages[0].recipient.id, data.timmy.person.id); + assert_eq!(timmy_messages[1].creator.id, data.timmy.person.id); + assert_eq!(timmy_messages[1].recipient.id, data.sara.person.id); + assert_eq!(timmy_messages[2].creator.id, data.sara.person.id); + assert_eq!(timmy_messages[2].recipient.id, data.timmy.person.id); + + let timmy_unread = NotificationView::get_unread_count(pool, &data.timmy.person, true).await?; + assert_eq!(2, timmy_unread); + + let timmy_unread_messages: Vec<_> = NotificationQuery { + unread_only: Some(true), + ..Default::default() + } + .list(pool, &data.timmy.person) + .await? + .into_iter() + .filter_map(to_pm) + .collect(); + + // The unread hides timmy's sent messages + assert_length!(2, &timmy_unread_messages); + assert_eq!(timmy_unread_messages[0].creator.id, data.jessica.id); + assert_eq!(timmy_unread_messages[0].recipient.id, data.timmy.person.id); + assert_eq!(timmy_unread_messages[1].creator.id, data.sara.person.id); + assert_eq!(timmy_unread_messages[1].recipient.id, data.timmy.person.id); + + cleanup(data, pool).await?; + + Ok(()) + } + + #[tokio::test] + #[serial] + async fn ensure_private_message_person_block() -> LemmyResult<()> { + let context = LemmyContext::init_test_context().await; + let pool = &mut context.pool(); + let data = init_data(pool).await?; + setup_private_messages(&data, &context).await?; + + // Make sure blocks are working + let timmy_blocks_sara_form = PersonBlockForm::new(data.timmy.person.id, data.sara.person.id); + + let inserted_block = PersonActions::block(pool, &timmy_blocks_sara_form).await?; + + assert_eq!( + (data.timmy.person.id, data.sara.person.id, true), + ( + inserted_block.person_id, + inserted_block.target_id, + inserted_block.blocked_at.is_some() + ) + ); + + let timmy_messages: Vec<_> = NotificationQuery { + unread_only: Some(true), + ..Default::default() + } + .list(pool, &data.timmy.person) + .await? + .into_iter() + .filter_map(to_pm) + .collect(); + + assert_length!(1, &timmy_messages); + + let timmy_unread = NotificationView::get_unread_count(pool, &data.timmy.person, true).await?; + assert_eq!(1, timmy_unread); + + cleanup(data, pool).await?; + + Ok(()) + } + + #[tokio::test] + #[serial] + async fn ensure_private_message_instance_block() -> LemmyResult<()> { + let context = LemmyContext::init_test_context().await; + let pool = &mut context.pool(); + let data = init_data(pool).await?; + setup_private_messages(&data, &context).await?; + + // Make sure instance_blocks are working + let timmy_blocks_instance_form = + InstanceBlockForm::new(data.timmy.person.id, data.sara.person.instance_id); + + let inserted_instance_block = InstanceActions::block(pool, &timmy_blocks_instance_form).await?; + + assert_eq!(data.timmy.person.id, inserted_instance_block.person_id); + assert_eq!( + data.sara.person.instance_id, + inserted_instance_block.instance_id + ); + assert!(inserted_instance_block.blocked_at.is_some()); + + let timmy_messages: Vec<_> = NotificationQuery { + unread_only: Some(true), + ..Default::default() + } + .list(pool, &data.timmy.person) + .await? + .into_iter() + .filter_map(to_pm) + .collect(); + + assert_length!(0, &timmy_messages); + + let timmy_unread = NotificationView::get_unread_count(pool, &data.timmy.person, true).await?; + assert_eq!(0, timmy_unread); + + cleanup(data, pool).await?; + + Ok(()) + } +} diff --git a/crates/api/api_utils/src/utils.rs b/crates/api/api_utils/src/utils.rs index b4dcaffdd..bd233b801 100644 --- a/crates/api/api_utils/src/utils.rs +++ b/crates/api/api_utils/src/utils.rs @@ -24,13 +24,13 @@ use lemmy_db_schema::{ ModRemovePostForm, }, oauth_account::OAuthAccount, - person::{Person, PersonActions, PersonUpdateForm}, + person::{Person, PersonUpdateForm}, post::{Post, PostActions, PostReadCommentsForm}, private_message::PrivateMessage, registration_application::RegistrationApplication, site::Site, }, - traits::{Blockable, Crud, Likeable}, + traits::{Crud, Likeable}, utils::DbPool, }; use lemmy_db_schema_file::enums::{FederationMode, RegistrationMode}; @@ -321,19 +321,6 @@ pub fn check_comment_deleted_or_removed(comment: &Comment) -> LemmyResult<()> { } } -pub async fn check_person_instance_community_block( - my_id: PersonId, - potential_blocker_id: PersonId, - community_instance_id: InstanceId, - community_id: CommunityId, - pool: &mut DbPool<'_>, -) -> LemmyResult<()> { - PersonActions::read_block(pool, potential_blocker_id, my_id).await?; - InstanceActions::read_block(pool, potential_blocker_id, community_instance_id).await?; - CommunityActions::read_block(pool, potential_blocker_id, community_id).await?; - Ok(()) -} - pub async fn check_local_vote_mode( score: i16, post_or_comment_id: PostOrCommentId, diff --git a/crates/apub/Cargo.toml b/crates/apub/Cargo.toml index bba2ee90e..4f539d477 100644 --- a/crates/apub/Cargo.toml +++ b/crates/apub/Cargo.toml @@ -15,12 +15,12 @@ name = "lemmy_apub" path = "src/lib.rs" doctest = false -[lints] -workspace = true - [features] full = [] +[lints] +workspace = true + [dependencies] lemmy_db_views_comment = { workspace = true, features = ["full"] } lemmy_db_views_community = { workspace = true, features = ["full"] } diff --git a/crates/apub/src/activities/create_or_update/comment.rs b/crates/apub/src/activities/create_or_update/comment.rs index 6ddaee396..2cc286089 100644 --- a/crates/apub/src/activities/create_or_update/comment.rs +++ b/crates/apub/src/activities/create_or_update/comment.rs @@ -14,8 +14,8 @@ use activitypub_federation::{ traits::{Activity, Actor, Object}, }; use lemmy_api_utils::{ - build_response::send_local_notifs, context::LemmyContext, + notify::NotifyData, utils::{check_is_mod_or_admin, check_post_deleted_or_removed}, }; use lemmy_apub_objects::{ @@ -27,7 +27,7 @@ use lemmy_apub_objects::{ }, }; use lemmy_db_schema::{ - newtypes::{PersonId, PostOrCommentId}, + newtypes::PersonId, source::{ activity::ActivitySendTargets, comment::{Comment, CommentActions, CommentLikeForm}, @@ -38,10 +38,7 @@ use lemmy_db_schema::{ traits::{Crud, Likeable}, }; use lemmy_db_views_site::SiteView; -use lemmy_utils::{ - error::{LemmyError, LemmyResult}, - utils::mention::scrape_text_for_mentions, -}; +use lemmy_utils::error::{LemmyError, LemmyResult}; use serde_json::{from_value, to_value}; use url::Url; @@ -137,12 +134,12 @@ impl Activity for CreateOrUpdateNote { // Need to do this check here instead of Note::from_json because we need the person who // send the activity, not the comment author. let existing_comment = self.object.id.dereference_local(context).await.ok(); + let (post, _) = self.object.get_parents(context).await?; if let (Some(distinguished), Some(existing_comment)) = (self.object.distinguished, existing_comment) { if distinguished != existing_comment.distinguished { let creator = self.actor.dereference(context).await?; - let (post, _) = self.object.get_parents(context).await?; check_is_mod_or_admin( &mut context.pool(), creator.id, @@ -172,19 +169,10 @@ impl Activity for CreateOrUpdateNote { // anyway. // TODO: for compatibility with other projects, it would be much better to read this from cc or // tags - let mentions = scrape_text_for_mentions(&comment.content); - // TODO: this fails in local community comment as CommentView::read() returns nothing - // without passing LocalUser - send_local_notifs( - mentions, - PostOrCommentId::Comment(comment.id), - &actor, - do_send_email, - context, - None, - local_instance_id, - ) - .await?; + let community = Community::read(&mut context.pool(), post.community_id).await?; + NotifyData::new(&post.0, Some(&comment.0), &actor, &community, do_send_email) + .send(context) + .await?; Ok(()) } } diff --git a/crates/apub/src/activities/create_or_update/post.rs b/crates/apub/src/activities/create_or_update/post.rs index a60eaa95b..3badb06fe 100644 --- a/crates/apub/src/activities/create_or_update/post.rs +++ b/crates/apub/src/activities/create_or_update/post.rs @@ -12,7 +12,7 @@ use activitypub_federation::{ protocol::verification::{verify_domains_match, verify_urls_match}, traits::{Activity, Object}, }; -use lemmy_api_utils::{build_response::send_local_notifs, context::LemmyContext}; +use lemmy_api_utils::{context::LemmyContext, notify::NotifyData}; use lemmy_apub_objects::{ objects::{community::ApubCommunity, person::ApubPerson, post::ApubPost}, utils::{ @@ -21,7 +21,7 @@ use lemmy_apub_objects::{ }, }; use lemmy_db_schema::{ - newtypes::{PersonId, PostOrCommentId}, + newtypes::PersonId, source::{ activity::ActivitySendTargets, community::Community, @@ -31,10 +31,7 @@ use lemmy_db_schema::{ traits::{Crud, Likeable}, }; use lemmy_db_views_site::SiteView; -use lemmy_utils::{ - error::{LemmyError, LemmyResult}, - utils::mention::scrape_text_for_mentions, -}; +use lemmy_utils::error::{LemmyError, LemmyResult}; use url::Url; impl CreateOrUpdatePage { @@ -110,7 +107,6 @@ impl Activity for CreateOrUpdatePage { async fn receive(self, context: &Data) -> LemmyResult<()> { let site_view = SiteView::read_local(&mut context.pool()).await?; - let local_instance_id = site_view.site.instance_id; let post = ApubPost::from_json(self.object, context).await?; @@ -125,18 +121,10 @@ impl Activity for CreateOrUpdatePage { self.kind == CreateOrUpdateType::Create && !site_view.local_site.disable_email_notifications; let actor = self.actor.dereference(context).await?; - // Send the post body mentions - let mentions = scrape_text_for_mentions(&post.body.clone().unwrap_or_default()); - send_local_notifs( - mentions, - PostOrCommentId::Post(post.id), - &actor, - do_send_email, - context, - None, - local_instance_id, - ) - .await?; + let community = Community::read(&mut context.pool(), post.community_id).await?; + NotifyData::new(&post.0, None, &actor, &community, do_send_email) + .send(context) + .await?; Ok(()) } diff --git a/crates/apub_objects/Cargo.toml b/crates/apub_objects/Cargo.toml index 77f231117..89f1afa6a 100644 --- a/crates/apub_objects/Cargo.toml +++ b/crates/apub_objects/Cargo.toml @@ -15,17 +15,18 @@ name = "lemmy_apub_objects" path = "src/lib.rs" doctest = false -[lints] -workspace = true - [features] full = [] +[lints] +workspace = true + [dependencies] lemmy_db_views_community_moderator = { workspace = true, features = ["full"] } lemmy_db_views_community_person_ban = { workspace = true, features = ["full"] } lemmy_db_views_local_user = { workspace = true, features = ["full"] } lemmy_db_views_site = { workspace = true, features = ["full"] } +lemmy_db_views_private_message = { workspace = true, features = ["full"] } lemmy_utils = { workspace = true, features = ["full"] } lemmy_db_schema = { workspace = true, features = ["full"] } lemmy_api_utils = { workspace = true, features = ["full"] } diff --git a/crates/apub_objects/src/objects/private_message.rs b/crates/apub_objects/src/objects/private_message.rs index 05f5ee576..388d17659 100644 --- a/crates/apub_objects/src/objects/private_message.rs +++ b/crates/apub_objects/src/objects/private_message.rs @@ -17,6 +17,7 @@ use activitypub_federation::{ use chrono::Utc; use lemmy_api_utils::{ context::LemmyContext, + notify::notify_private_message, plugins::{plugin_hook_after, plugin_hook_before}, utils::{check_private_messages_enabled, get_url_blocklist, process_markdown, slur_regex}, }; @@ -29,6 +30,7 @@ use lemmy_db_schema::{ traits::{Blockable, Crud}, }; use lemmy_db_views_local_user::LocalUserView; +use lemmy_db_views_private_message::PrivateMessageView; use lemmy_utils::{ error::{LemmyError, LemmyErrorType, LemmyResult}, utils::markdown::markdown_to_html, @@ -158,7 +160,6 @@ impl Object for ApubPrivateMessage { published_at: note.published, updated_at: note.updated, deleted: Some(false), - read: None, ap_id: Some(note.id.into()), local: Some(false), }; @@ -166,6 +167,8 @@ impl Object for ApubPrivateMessage { let timestamp = note.updated.or(note.published).unwrap_or_else(Utc::now); let pm = DbPrivateMessage::insert_apub(&mut context.pool(), timestamp, &form).await?; plugin_hook_after("after_receive_federated_private_message", &pm)?; + let view = PrivateMessageView::read(&mut context.pool(), pm.id).await?; + notify_private_message(&view, pm.updated_at.is_none(), context).await?; Ok(pm.into()) } } diff --git a/crates/db_schema/src/impls/comment_reply.rs b/crates/db_schema/src/impls/comment_reply.rs deleted file mode 100644 index e6efe6c26..000000000 --- a/crates/db_schema/src/impls/comment_reply.rs +++ /dev/null @@ -1,94 +0,0 @@ -use crate::{ - diesel::OptionalExtension, - newtypes::{CommentId, CommentReplyId, PersonId}, - source::comment_reply::{CommentReply, CommentReplyInsertForm, CommentReplyUpdateForm}, - traits::Crud, - utils::{get_conn, DbPool}, -}; -use diesel::{dsl::insert_into, ExpressionMethods, QueryDsl}; -use diesel_async::RunQueryDsl; -use lemmy_db_schema_file::schema::comment_reply; -use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; - -impl Crud for CommentReply { - type InsertForm = CommentReplyInsertForm; - type UpdateForm = CommentReplyUpdateForm; - type IdType = CommentReplyId; - - async fn create( - pool: &mut DbPool<'_>, - comment_reply_form: &Self::InsertForm, - ) -> LemmyResult { - let conn = &mut get_conn(pool).await?; - - // since the return here isnt utilized, we dont need to do an update - // but get_result doesn't return the existing row here - insert_into(comment_reply::table) - .values(comment_reply_form) - .on_conflict((comment_reply::recipient_id, comment_reply::comment_id)) - .do_update() - .set(comment_reply_form) - .get_result::(conn) - .await - .with_lemmy_type(LemmyErrorType::CouldntCreateCommentReply) - } - - async fn update( - pool: &mut DbPool<'_>, - comment_reply_id: CommentReplyId, - comment_reply_form: &Self::UpdateForm, - ) -> LemmyResult { - let conn = &mut get_conn(pool).await?; - diesel::update(comment_reply::table.find(comment_reply_id)) - .set(comment_reply_form) - .get_result::(conn) - .await - .with_lemmy_type(LemmyErrorType::CouldntUpdateCommentReply) - } -} - -impl CommentReply { - pub async fn mark_all_as_read( - pool: &mut DbPool<'_>, - for_recipient_id: PersonId, - ) -> LemmyResult> { - let conn = &mut get_conn(pool).await?; - diesel::update( - comment_reply::table - .filter(comment_reply::recipient_id.eq(for_recipient_id)) - .filter(comment_reply::read.eq(false)), - ) - .set(comment_reply::read.eq(true)) - .get_results::(conn) - .await - .with_lemmy_type(LemmyErrorType::CouldntMarkCommentReplyAsRead) - } - - pub async fn read_by_comment( - pool: &mut DbPool<'_>, - for_comment_id: CommentId, - ) -> LemmyResult> { - let conn = &mut get_conn(pool).await?; - comment_reply::table - .filter(comment_reply::comment_id.eq(for_comment_id)) - .first(conn) - .await - .optional() - .with_lemmy_type(LemmyErrorType::NotFound) - } - - pub async fn read_by_comment_and_person( - pool: &mut DbPool<'_>, - for_comment_id: CommentId, - for_recipient_id: PersonId, - ) -> LemmyResult> { - let conn = &mut get_conn(pool).await?; - comment_reply::table - .filter(comment_reply::comment_id.eq(for_comment_id)) - .filter(comment_reply::recipient_id.eq(for_recipient_id)) - .first(conn) - .await - .optional() - .with_lemmy_type(LemmyErrorType::NotFound) - } -} diff --git a/crates/db_schema/src/impls/community.rs b/crates/db_schema/src/impls/community.rs index 32e5364e8..dd69c6b16 100644 --- a/crates/db_schema/src/impls/community.rs +++ b/crates/db_schema/src/impls/community.rs @@ -37,8 +37,8 @@ use diesel::{ use diesel_async::RunQueryDsl; use diesel_uplete::{uplete, UpleteCount}; use lemmy_db_schema_file::{ - enums::{CommunityFollowerState, CommunityVisibility, ListingType}, - schema::{comment, community, community_actions, instance, post}, + enums::{CommunityFollowerState, CommunityNotificationsMode, CommunityVisibility, ListingType}, + schema::{comment, community, community_actions, instance, local_user, post}, }; use lemmy_utils::{ error::{LemmyError, LemmyErrorExt, LemmyErrorType, LemmyResult}, @@ -455,6 +455,61 @@ impl CommunityActions { .await .map_err(|_e: Arc| LemmyErrorType::NotFound.into()) } + + pub async fn update_notification_state( + community_id: CommunityId, + person_id: PersonId, + new_state: CommunityNotificationsMode, + pool: &mut DbPool<'_>, + ) -> LemmyResult<()> { + let conn = &mut get_conn(pool).await?; + let form = ( + community_actions::person_id.eq(person_id), + community_actions::community_id.eq(community_id), + community_actions::notifications.eq(new_state), + ); + + insert_into(community_actions::table) + .values(form.clone()) + .on_conflict(( + community_actions::person_id, + community_actions::community_id, + )) + .do_update() + .set(form) + .execute(conn) + .await?; + Ok(()) + } + + pub async fn list_subscribers( + community_id: CommunityId, + is_post: bool, + pool: &mut DbPool<'_>, + ) -> LemmyResult> { + let conn = &mut get_conn(pool).await?; + + let mut query = community_actions::table + .inner_join(local_user::table.on(community_actions::person_id.eq(local_user::person_id))) + .filter(community_actions::community_id.eq(community_id)) + .select(local_user::person_id) + .into_boxed(); + if is_post { + query = query.filter( + community_actions::notifications + .eq(CommunityNotificationsMode::AllPosts) + .or(community_actions::notifications.eq(CommunityNotificationsMode::AllPostsAndComments)), + ); + } else { + query = query.filter( + community_actions::notifications.eq(CommunityNotificationsMode::AllPostsAndComments), + ); + } + query + .get_results(conn) + .await + .with_lemmy_type(LemmyErrorType::NotFound) + } } impl Bannable for CommunityActions { diff --git a/crates/db_schema/src/impls/mod.rs b/crates/db_schema/src/impls/mod.rs index edbc4b496..c259cb3d3 100644 --- a/crates/db_schema/src/impls/mod.rs +++ b/crates/db_schema/src/impls/mod.rs @@ -2,7 +2,6 @@ pub mod activity; pub mod actor_language; pub mod captcha_answer; pub mod comment; -pub mod comment_reply; pub mod comment_report; pub mod community; pub mod community_report; @@ -22,12 +21,11 @@ pub mod local_user; pub mod login_token; pub mod mod_log; pub mod multi_community; +pub mod notification; pub mod oauth_account; pub mod oauth_provider; pub mod password_reset_request; pub mod person; -pub mod person_comment_mention; -pub mod person_post_mention; pub mod post; pub mod post_report; pub mod post_tag; diff --git a/crates/db_schema/src/impls/notification.rs b/crates/db_schema/src/impls/notification.rs new file mode 100644 index 000000000..00aa679b4 --- /dev/null +++ b/crates/db_schema/src/impls/notification.rs @@ -0,0 +1,69 @@ +use crate::{ + newtypes::{CommentId, NotificationId, PersonId}, + source::notification::{Notification, NotificationInsertForm}, + utils::{get_conn, DbPool}, +}; +use diesel::{ + dsl::{insert_into, update}, + ExpressionMethods, + QueryDsl, +}; +use diesel_async::RunQueryDsl; +use lemmy_db_schema_file::schema::notification; +use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; + +impl Notification { + pub async fn create(pool: &mut DbPool<'_>, form: &NotificationInsertForm) -> LemmyResult { + let conn = &mut get_conn(pool).await?; + insert_into(notification::table) + .values(form) + .get_result::(conn) + .await + .with_lemmy_type(LemmyErrorType::CouldntCreateNotification) + } + + pub async fn read_by_comment_id( + pool: &mut DbPool<'_>, + comment_id: CommentId, + ) -> LemmyResult { + let conn = &mut get_conn(pool).await?; + notification::table + .filter(notification::comment_id.eq(comment_id)) + .get_result::(conn) + .await + .with_lemmy_type(LemmyErrorType::NotFound) + } + + pub async fn mark_all_as_read( + pool: &mut DbPool<'_>, + for_recipient_id: PersonId, + ) -> LemmyResult { + let conn = &mut get_conn(pool).await?; + diesel::update( + notification::table + .filter(notification::recipient_id.eq(for_recipient_id)) + .filter(notification::read.eq(false)), + ) + .set(notification::read.eq(true)) + .execute(conn) + .await + .with_lemmy_type(LemmyErrorType::CouldntUpdateNotification) + } + + pub async fn mark_read_by_id_and_person( + pool: &mut DbPool<'_>, + notification_id: NotificationId, + for_recipient_id: PersonId, + ) -> LemmyResult { + let conn = &mut get_conn(pool).await?; + update( + notification::table + .filter(notification::id.eq(notification_id)) + .filter(notification::recipient_id.eq(for_recipient_id)), + ) + .set(notification::read.eq(true)) + .execute(conn) + .await + .with_lemmy_type(LemmyErrorType::NotFound) + } +} diff --git a/crates/db_schema/src/impls/person_comment_mention.rs b/crates/db_schema/src/impls/person_comment_mention.rs deleted file mode 100644 index 57f034b98..000000000 --- a/crates/db_schema/src/impls/person_comment_mention.rs +++ /dev/null @@ -1,84 +0,0 @@ -use crate::{ - diesel::OptionalExtension, - newtypes::{CommentId, PersonCommentMentionId, PersonId}, - source::person_comment_mention::{ - PersonCommentMention, - PersonCommentMentionInsertForm, - PersonCommentMentionUpdateForm, - }, - traits::Crud, - utils::{get_conn, DbPool}, -}; -use diesel::{dsl::insert_into, ExpressionMethods, QueryDsl}; -use diesel_async::RunQueryDsl; -use lemmy_db_schema_file::schema::person_comment_mention; -use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; - -impl Crud for PersonCommentMention { - type InsertForm = PersonCommentMentionInsertForm; - type UpdateForm = PersonCommentMentionUpdateForm; - type IdType = PersonCommentMentionId; - - async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult { - let conn = &mut get_conn(pool).await?; - // since the return here isnt utilized, we dont need to do an update - // but get_result doesn't return the existing row here - insert_into(person_comment_mention::table) - .values(form) - .on_conflict(( - person_comment_mention::recipient_id, - person_comment_mention::comment_id, - )) - .do_update() - .set(form) - .get_result::(conn) - .await - .with_lemmy_type(LemmyErrorType::CouldntCreatePersonCommentMention) - } - - async fn update( - pool: &mut DbPool<'_>, - person_comment_mention_id: PersonCommentMentionId, - person_comment_mention_form: &Self::UpdateForm, - ) -> LemmyResult { - let conn = &mut get_conn(pool).await?; - diesel::update(person_comment_mention::table.find(person_comment_mention_id)) - .set(person_comment_mention_form) - .get_result::(conn) - .await - .with_lemmy_type(LemmyErrorType::CouldntUpdatePersonCommentMention) - } -} - -impl PersonCommentMention { - pub async fn mark_all_as_read( - pool: &mut DbPool<'_>, - for_recipient_id: PersonId, - ) -> LemmyResult> { - let conn = &mut get_conn(pool).await?; - diesel::update( - person_comment_mention::table - .filter(person_comment_mention::recipient_id.eq(for_recipient_id)) - .filter(person_comment_mention::read.eq(false)), - ) - .set(person_comment_mention::read.eq(true)) - .get_results::(conn) - .await - .with_lemmy_type(LemmyErrorType::CouldntUpdatePersonCommentMention) - } - - pub async fn read_by_comment_and_person( - pool: &mut DbPool<'_>, - for_comment_id: CommentId, - for_recipient_id: PersonId, - ) -> LemmyResult> { - let conn = &mut get_conn(pool).await?; - person_comment_mention::table - .filter(person_comment_mention::comment_id.eq(for_comment_id)) - .filter(person_comment_mention::recipient_id.eq(for_recipient_id)) - .first(conn) - .await - .optional() - .with_lemmy_type(LemmyErrorType::NotFound) - } -} diff --git a/crates/db_schema/src/impls/person_post_mention.rs b/crates/db_schema/src/impls/person_post_mention.rs deleted file mode 100644 index 4d3756432..000000000 --- a/crates/db_schema/src/impls/person_post_mention.rs +++ /dev/null @@ -1,87 +0,0 @@ -use crate::{ - diesel::OptionalExtension, - newtypes::{PersonId, PersonPostMentionId, PostId}, - source::person_post_mention::{ - PersonPostMention, - PersonPostMentionInsertForm, - PersonPostMentionUpdateForm, - }, - traits::Crud, - utils::{get_conn, DbPool}, -}; -use diesel::{dsl::insert_into, ExpressionMethods, QueryDsl}; -use diesel_async::RunQueryDsl; -use lemmy_db_schema_file::schema::person_post_mention; -use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; - -impl Crud for PersonPostMention { - type InsertForm = PersonPostMentionInsertForm; - type UpdateForm = PersonPostMentionUpdateForm; - type IdType = PersonPostMentionId; - - async fn create( - pool: &mut DbPool<'_>, - person_post_mention_form: &Self::InsertForm, - ) -> LemmyResult { - let conn = &mut get_conn(pool).await?; - // since the return here isnt utilized, we dont need to do an update - // but get_result doesn't return the existing row here - insert_into(person_post_mention::table) - .values(person_post_mention_form) - .on_conflict(( - person_post_mention::recipient_id, - person_post_mention::post_id, - )) - .do_update() - .set(person_post_mention_form) - .get_result::(conn) - .await - .with_lemmy_type(LemmyErrorType::CouldntCreatePersonPostMention) - } - - async fn update( - pool: &mut DbPool<'_>, - person_post_mention_id: PersonPostMentionId, - person_post_mention_form: &Self::UpdateForm, - ) -> LemmyResult { - let conn = &mut get_conn(pool).await?; - diesel::update(person_post_mention::table.find(person_post_mention_id)) - .set(person_post_mention_form) - .get_result::(conn) - .await - .with_lemmy_type(LemmyErrorType::CouldntUpdatePersonPostMention) - } -} - -impl PersonPostMention { - pub async fn mark_all_as_read( - pool: &mut DbPool<'_>, - for_recipient_id: PersonId, - ) -> LemmyResult> { - let conn = &mut get_conn(pool).await?; - diesel::update( - person_post_mention::table - .filter(person_post_mention::recipient_id.eq(for_recipient_id)) - .filter(person_post_mention::read.eq(false)), - ) - .set(person_post_mention::read.eq(true)) - .get_results::(conn) - .await - .with_lemmy_type(LemmyErrorType::CouldntUpdatePersonPostMention) - } - - pub async fn read_by_post_and_person( - pool: &mut DbPool<'_>, - for_post_id: PostId, - for_recipient_id: PersonId, - ) -> LemmyResult> { - let conn = &mut get_conn(pool).await?; - person_post_mention::table - .filter(person_post_mention::post_id.eq(for_post_id)) - .filter(person_post_mention::recipient_id.eq(for_recipient_id)) - .first(conn) - .await - .optional() - .with_lemmy_type(LemmyErrorType::NotFound) - } -} diff --git a/crates/db_schema/src/impls/post.rs b/crates/db_schema/src/impls/post.rs index 740ea86ca..becb56056 100644 --- a/crates/db_schema/src/impls/post.rs +++ b/crates/db_schema/src/impls/post.rs @@ -24,7 +24,6 @@ use crate::{ SITEMAP_LIMIT, }, }; -use ::url::Url; use chrono::{DateTime, Utc}; use diesel::{ dsl::{count, insert_into, not, update}, @@ -39,11 +38,15 @@ use diesel::{ }; use diesel_async::RunQueryDsl; use diesel_uplete::{uplete, UpleteCount}; -use lemmy_db_schema_file::schema::{community, person, post, post_actions}; +use lemmy_db_schema_file::{ + enums::PostNotificationsMode, + schema::{community, local_user, person, post, post_actions}, +}; use lemmy_utils::{ error::{LemmyErrorExt, LemmyErrorExt2, LemmyErrorType, LemmyResult}, settings::structs::Settings, }; +use url::Url; impl Crud for Post { type InsertForm = PostInsertForm; @@ -542,9 +545,7 @@ impl PostActions { .map(|post_id| (PostReadForm::new(*post_id, person_id))) .collect::>() } -} -impl PostActions { pub async fn read( pool: &mut DbPool<'_>, post_id: PostId, @@ -570,6 +571,45 @@ impl PostActions { .ok_or(LemmyErrorType::CouldntParsePaginationToken)?; Self::read(pool, PostId(*post_id), PersonId(*person_id)).await } + + pub async fn update_notification_state( + post_id: PostId, + person_id: PersonId, + new_state: PostNotificationsMode, + pool: &mut DbPool<'_>, + ) -> LemmyResult<()> { + let conn = &mut get_conn(pool).await?; + let form = ( + post_actions::person_id.eq(person_id), + post_actions::post_id.eq(post_id), + post_actions::notifications.eq(new_state), + ); + + insert_into(post_actions::table) + .values(form.clone()) + .on_conflict((post_actions::person_id, post_actions::post_id)) + .do_update() + .set(form) + .execute(conn) + .await?; + Ok(()) + } + + pub async fn list_subscribers( + post_id: PostId, + pool: &mut DbPool<'_>, + ) -> LemmyResult> { + let conn = &mut get_conn(pool).await?; + + post_actions::table + .inner_join(local_user::table.on(post_actions::person_id.eq(local_user::person_id))) + .filter(post_actions::post_id.eq(post_id)) + .filter(post_actions::notifications.eq(PostNotificationsMode::AllComments)) + .select(local_user::person_id) + .get_results(conn) + .await + .with_lemmy_type(LemmyErrorType::NotFound) + } } #[cfg(test)] diff --git a/crates/db_schema/src/impls/private_message.rs b/crates/db_schema/src/impls/private_message.rs index 3a970ce38..221b819ef 100644 --- a/crates/db_schema/src/impls/private_message.rs +++ b/crates/db_schema/src/impls/private_message.rs @@ -63,22 +63,6 @@ impl PrivateMessage { .with_lemmy_type(LemmyErrorType::CouldntCreatePrivateMessage) } - pub async fn mark_all_as_read( - pool: &mut DbPool<'_>, - for_recipient_id: PersonId, - ) -> LemmyResult> { - let conn = &mut get_conn(pool).await?; - diesel::update( - private_message::table - .filter(private_message::recipient_id.eq(for_recipient_id)) - .filter(private_message::read.eq(false)), - ) - .set(private_message::read.eq(true)) - .get_results::(conn) - .await - .with_lemmy_type(LemmyErrorType::CouldntUpdatePrivateMessage) - } - pub async fn read_from_apub_id( pool: &mut DbPool<'_>, object_id: Url, @@ -161,7 +145,6 @@ mod tests { creator_id: inserted_creator.id, recipient_id: inserted_recipient.id, deleted: false, - read: false, updated_at: None, published_at: inserted_private_message.published_at, ap_id: Url::parse(&format!( @@ -195,15 +178,6 @@ mod tests { }, ) .await?; - let marked_read_private_message = PrivateMessage::update( - pool, - inserted_private_message.id, - &PrivateMessageUpdateForm { - read: Some(true), - ..Default::default() - }, - ) - .await?; Person::delete(pool, inserted_creator.id).await?; Person::delete(pool, inserted_recipient.id).await?; Instance::delete(pool, inserted_instance.id).await?; @@ -212,7 +186,6 @@ mod tests { assert_eq!(expected_private_message, updated_private_message); assert_eq!(expected_private_message, inserted_private_message); assert!(deleted_private_message.deleted); - assert!(marked_read_private_message.read); Ok(()) } diff --git a/crates/db_schema/src/lib.rs b/crates/db_schema/src/lib.rs index 21d6b5ec2..b28ea0036 100644 --- a/crates/db_schema/src/lib.rs +++ b/crates/db_schema/src/lib.rs @@ -117,12 +117,12 @@ pub enum ModlogActionType { #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(export))] /// A list of possible types for the inbox. -pub enum InboxDataType { +pub enum NotificationDataType { All, - CommentReply, - CommentMention, - PostMention, + Reply, + Mention, PrivateMessage, + Subscribed, } #[derive(EnumString, Display, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/crates/db_schema/src/newtypes.rs b/crates/db_schema/src/newtypes.rs index 2ac034ede..5c0de1459 100644 --- a/crates/db_schema/src/newtypes.rs +++ b/crates/db_schema/src/newtypes.rs @@ -93,15 +93,7 @@ impl fmt::Display for PrivateMessageId { #[cfg_attr(feature = "full", derive(DieselNewType))] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] -/// The person comment mention id. -pub struct PersonCommentMentionId(pub i32); - -#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] -#[cfg_attr(feature = "full", derive(DieselNewType))] -#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] -/// The person post mention id. -pub struct PersonPostMentionId(pub i32); +pub struct NotificationId(pub i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] @@ -145,13 +137,6 @@ pub struct SiteId(pub i32); /// The language id. pub struct LanguageId(pub i32); -#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] -#[cfg_attr(feature = "full", derive(DieselNewType))] -#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] -/// The comment reply id. -pub struct CommentReplyId(pub i32); - #[derive( Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default, Ord, PartialOrd, )] @@ -242,13 +227,6 @@ pub struct PersonLikedCombinedId(i32); #[cfg_attr(feature = "full", derive(DieselNewType))] pub struct ModlogCombinedId(i32); -#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] -#[cfg_attr(feature = "full", derive(DieselNewType))] -#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts-rs", ts(export))] -/// The inbox combined id -pub struct InboxCombinedId(i32); - #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType))] /// The search combined id diff --git a/crates/db_schema/src/source/combined/inbox.rs b/crates/db_schema/src/source/combined/inbox.rs deleted file mode 100644 index 83643a93c..000000000 --- a/crates/db_schema/src/source/combined/inbox.rs +++ /dev/null @@ -1,35 +0,0 @@ -use crate::newtypes::{ - CommentReplyId, - InboxCombinedId, - PersonCommentMentionId, - PersonPostMentionId, - PrivateMessageId, -}; -use chrono::{DateTime, Utc}; -#[cfg(feature = "full")] -use i_love_jesus::CursorKeysModule; -#[cfg(feature = "full")] -use lemmy_db_schema_file::schema::inbox_combined; -use serde::{Deserialize, Serialize}; -use serde_with::skip_serializing_none; - -#[skip_serializing_none] -#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Clone)] -#[cfg_attr( - feature = "full", - derive(Identifiable, Queryable, Selectable, CursorKeysModule) -)] -#[cfg_attr(feature = "full", diesel(table_name = inbox_combined))] -#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] -#[cfg_attr(feature = "full", cursor_keys_module(name = inbox_combined_keys))] -#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] -/// A combined inbox table. -pub struct InboxCombined { - pub id: InboxCombinedId, - pub published_at: DateTime, - pub comment_reply_id: Option, - pub person_comment_mention_id: Option, - pub person_post_mention_id: Option, - pub private_message_id: Option, -} diff --git a/crates/db_schema/src/source/combined/mod.rs b/crates/db_schema/src/source/combined/mod.rs index 791227b0b..6b1e8f23a 100644 --- a/crates/db_schema/src/source/combined/mod.rs +++ b/crates/db_schema/src/source/combined/mod.rs @@ -1,4 +1,3 @@ -pub mod inbox; pub mod modlog; pub mod person_content; pub mod person_liked; diff --git a/crates/db_schema/src/source/comment_reply.rs b/crates/db_schema/src/source/comment_reply.rs deleted file mode 100644 index 7fa3a9878..000000000 --- a/crates/db_schema/src/source/comment_reply.rs +++ /dev/null @@ -1,38 +0,0 @@ -use crate::newtypes::{CommentId, CommentReplyId, PersonId}; -use chrono::{DateTime, Utc}; -#[cfg(feature = "full")] -use lemmy_db_schema_file::schema::comment_reply; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] -#[cfg_attr( - feature = "full", - derive(Queryable, Selectable, Associations, Identifiable) -)] -#[cfg_attr(feature = "full", diesel(belongs_to(crate::source::comment::Comment)))] -#[cfg_attr(feature = "full", diesel(table_name = comment_reply))] -#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] -#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] -/// A comment reply. -pub struct CommentReply { - pub id: CommentReplyId, - pub recipient_id: PersonId, - pub comment_id: CommentId, - pub read: bool, - pub published_at: DateTime, -} - -#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] -#[cfg_attr(feature = "full", diesel(table_name = comment_reply))] -pub struct CommentReplyInsertForm { - pub recipient_id: PersonId, - pub comment_id: CommentId, - pub read: Option, -} - -#[cfg_attr(feature = "full", derive(AsChangeset))] -#[cfg_attr(feature = "full", diesel(table_name = comment_reply))] -pub struct CommentReplyUpdateForm { - pub read: Option, -} diff --git a/crates/db_schema/src/source/community.rs b/crates/db_schema/src/source/community.rs index b7f51afff..a42ff7e7f 100644 --- a/crates/db_schema/src/source/community.rs +++ b/crates/db_schema/src/source/community.rs @@ -4,7 +4,11 @@ use crate::{ source::placeholder_apub_url, }; use chrono::{DateTime, Utc}; -use lemmy_db_schema_file::enums::{CommunityFollowerState, CommunityVisibility}; +use lemmy_db_schema_file::enums::{ + CommunityFollowerState, + CommunityNotificationsMode, + CommunityVisibility, +}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] @@ -210,6 +214,7 @@ pub struct CommunityActions { pub received_ban_at: Option>, /// When their ban expires. pub ban_expires_at: Option>, + pub notifications: Option, } #[derive(Clone, derive_new::new)] diff --git a/crates/db_schema/src/source/mod.rs b/crates/db_schema/src/source/mod.rs index bb407eadf..e63438e2a 100644 --- a/crates/db_schema/src/source/mod.rs +++ b/crates/db_schema/src/source/mod.rs @@ -7,7 +7,6 @@ pub mod actor_language; pub mod captcha_answer; pub mod combined; pub mod comment; -pub mod comment_reply; pub mod comment_report; pub mod community; pub mod community_report; @@ -28,12 +27,11 @@ pub mod local_user; pub mod login_token; pub mod mod_log; pub mod multi_community; +pub mod notification; pub mod oauth_account; pub mod oauth_provider; pub mod password_reset_request; pub mod person; -pub mod person_comment_mention; -pub mod person_post_mention; pub mod post; pub mod post_report; pub mod post_tag; diff --git a/crates/db_schema/src/source/notification.rs b/crates/db_schema/src/source/notification.rs new file mode 100644 index 000000000..b0184e982 --- /dev/null +++ b/crates/db_schema/src/source/notification.rs @@ -0,0 +1,77 @@ +use crate::newtypes::{CommentId, NotificationId, PersonId, PostId, PrivateMessageId}; +use chrono::{DateTime, Utc}; +#[cfg(feature = "full")] +use i_love_jesus::CursorKeysModule; +use lemmy_db_schema_file::enums::NotificationTypes; +#[cfg(feature = "full")] +use lemmy_db_schema_file::schema::notification; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[cfg_attr( + feature = "full", + derive(Queryable, Selectable, Identifiable, CursorKeysModule) +)] +#[cfg_attr(feature = "full", diesel(table_name = notification))] +#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] +#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] +#[cfg_attr(feature = "full", cursor_keys_module(name = notification_keys))] +pub struct Notification { + pub id: NotificationId, + pub recipient_id: PersonId, + pub comment_id: Option, + pub read: bool, + pub published_at: DateTime, + pub kind: NotificationTypes, + pub post_id: Option, + pub private_message_id: Option, +} + +#[cfg_attr(feature = "full", derive(Insertable))] +#[cfg_attr(feature = "full", diesel(table_name = notification))] +pub struct NotificationInsertForm { + pub recipient_id: PersonId, + pub comment_id: Option, + pub kind: NotificationTypes, + pub post_id: Option, + pub private_message_id: Option, +} + +impl NotificationInsertForm { + pub fn new_post(post_id: PostId, recipient_id: PersonId, kind: NotificationTypes) -> Self { + Self { + post_id: Some(post_id), + comment_id: None, + private_message_id: None, + recipient_id, + kind, + } + } + pub fn new_comment( + comment_id: CommentId, + recipient_id: PersonId, + kind: NotificationTypes, + ) -> Self { + Self { + post_id: None, + comment_id: Some(comment_id), + private_message_id: None, + recipient_id, + kind, + } + } + pub fn new_private_message( + private_message_id: PrivateMessageId, + recipient_id: PersonId, + kind: NotificationTypes, + ) -> Self { + Self { + post_id: None, + comment_id: None, + private_message_id: Some(private_message_id), + recipient_id, + kind, + } + } +} diff --git a/crates/db_schema/src/source/person_comment_mention.rs b/crates/db_schema/src/source/person_comment_mention.rs deleted file mode 100644 index 21cc2aaab..000000000 --- a/crates/db_schema/src/source/person_comment_mention.rs +++ /dev/null @@ -1,38 +0,0 @@ -use crate::newtypes::{CommentId, PersonCommentMentionId, PersonId}; -use chrono::{DateTime, Utc}; -#[cfg(feature = "full")] -use lemmy_db_schema_file::schema::person_comment_mention; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] -#[cfg_attr( - feature = "full", - derive(Queryable, Selectable, Associations, Identifiable) -)] -#[cfg_attr(feature = "full", diesel(belongs_to(crate::source::comment::Comment)))] -#[cfg_attr(feature = "full", diesel(table_name = person_comment_mention))] -#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] -#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] -/// A person mention. -pub struct PersonCommentMention { - pub id: PersonCommentMentionId, - pub recipient_id: PersonId, - pub comment_id: CommentId, - pub read: bool, - pub published_at: DateTime, -} - -#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] -#[cfg_attr(feature = "full", diesel(table_name = person_comment_mention))] -pub struct PersonCommentMentionInsertForm { - pub recipient_id: PersonId, - pub comment_id: CommentId, - pub read: Option, -} - -#[cfg_attr(feature = "full", derive(AsChangeset))] -#[cfg_attr(feature = "full", diesel(table_name = person_comment_mention))] -pub struct PersonCommentMentionUpdateForm { - pub read: Option, -} diff --git a/crates/db_schema/src/source/person_post_mention.rs b/crates/db_schema/src/source/person_post_mention.rs deleted file mode 100644 index 0322f1467..000000000 --- a/crates/db_schema/src/source/person_post_mention.rs +++ /dev/null @@ -1,38 +0,0 @@ -use crate::newtypes::{PersonId, PersonPostMentionId, PostId}; -use chrono::{DateTime, Utc}; -#[cfg(feature = "full")] -use lemmy_db_schema_file::schema::person_post_mention; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] -#[cfg_attr( - feature = "full", - derive(Queryable, Selectable, Associations, Identifiable) -)] -#[cfg_attr(feature = "full", diesel(belongs_to(crate::source::post::Post)))] -#[cfg_attr(feature = "full", diesel(table_name = person_post_mention))] -#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] -#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] -/// A person mention. -pub struct PersonPostMention { - pub id: PersonPostMentionId, - pub recipient_id: PersonId, - pub post_id: PostId, - pub read: bool, - pub published_at: DateTime, -} - -#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] -#[cfg_attr(feature = "full", diesel(table_name = person_post_mention))] -pub struct PersonPostMentionInsertForm { - pub recipient_id: PersonId, - pub post_id: PostId, - pub read: Option, -} - -#[cfg_attr(feature = "full", derive(AsChangeset))] -#[cfg_attr(feature = "full", diesel(table_name = person_post_mention))] -pub struct PersonPostMentionUpdateForm { - pub read: Option, -} diff --git a/crates/db_schema/src/source/post.rs b/crates/db_schema/src/source/post.rs index d4bceb119..086a0b9ec 100644 --- a/crates/db_schema/src/source/post.rs +++ b/crates/db_schema/src/source/post.rs @@ -1,5 +1,6 @@ use crate::newtypes::{CommunityId, DbUrl, LanguageId, PersonId, PostId}; use chrono::{DateTime, Utc}; +use lemmy_db_schema_file::enums::PostNotificationsMode; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[cfg(feature = "full")] @@ -201,6 +202,7 @@ pub struct PostActions { pub like_score: Option, /// When the post was hidden. pub hidden_at: Option>, + pub notifications: Option, } #[derive(Clone, derive_new::new)] diff --git a/crates/db_schema/src/source/private_message.rs b/crates/db_schema/src/source/private_message.rs index 88e4c4a6a..a04ad89c9 100644 --- a/crates/db_schema/src/source/private_message.rs +++ b/crates/db_schema/src/source/private_message.rs @@ -26,7 +26,6 @@ pub struct PrivateMessage { pub recipient_id: PersonId, pub content: String, pub deleted: bool, - pub read: bool, pub published_at: DateTime, pub updated_at: Option>, pub ap_id: DbUrl, @@ -47,8 +46,6 @@ pub struct PrivateMessageInsertForm { #[new(default)] pub deleted: Option, #[new(default)] - pub read: Option, - #[new(default)] pub published_at: Option>, #[new(default)] pub updated_at: Option>, @@ -64,7 +61,6 @@ pub struct PrivateMessageInsertForm { pub struct PrivateMessageUpdateForm { pub content: Option, pub deleted: Option, - pub read: Option, pub published_at: Option>, pub updated_at: Option>>, pub ap_id: Option, diff --git a/crates/db_schema_file/src/enums.rs b/crates/db_schema_file/src/enums.rs index af6e7b28c..65482ece6 100644 --- a/crates/db_schema_file/src/enums.rs +++ b/crates/db_schema_file/src/enums.rs @@ -226,3 +226,59 @@ pub enum VoteShow { ShowForOthers, Hide, } + +#[derive( + EnumString, Display, Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash, +)] +#[cfg_attr(feature = "full", derive(DbEnum))] +#[cfg_attr( + feature = "full", + ExistingTypePath = "crate::schema::sql_types::PostNotificationsModeEnum" +)] +#[cfg_attr(feature = "full", DbValueStyle = "verbatim")] +#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts-rs", ts(export))] +/// Available settings for post notifications +pub enum PostNotificationsMode { + AllComments, + #[default] + RepliesAndMentions, + Mute, +} + +#[derive( + EnumString, Display, Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Hash, +)] +#[cfg_attr(feature = "full", derive(DbEnum))] +#[cfg_attr( + feature = "full", + ExistingTypePath = "crate::schema::sql_types::CommunityNotificationsModeEnum" +)] +#[cfg_attr(feature = "full", DbValueStyle = "verbatim")] +#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts-rs", ts(export))] +/// Available settings for community notifications +pub enum CommunityNotificationsMode { + AllPostsAndComments, + AllPosts, + #[default] + RepliesAndMentions, + Mute, +} + +#[derive(EnumString, Display, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "full", derive(DbEnum))] +#[cfg_attr( + feature = "full", + ExistingTypePath = "crate::schema::sql_types::NotificationTypeEnum" +)] +#[cfg_attr(feature = "full", DbValueStyle = "verbatim")] +#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts-rs", ts(export))] +/// Types of notifications which can be received in inbox +pub enum NotificationTypes { + Mention, + Reply, + Subscribed, + PrivateMessage, +} diff --git a/crates/db_schema_file/src/schema.rs b/crates/db_schema_file/src/schema.rs index 2039690f5..38664d85a 100644 --- a/crates/db_schema_file/src/schema.rs +++ b/crates/db_schema_file/src/schema.rs @@ -13,6 +13,10 @@ pub mod sql_types { #[diesel(postgres_type(name = "community_follower_state"))] pub struct CommunityFollowerState; + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "community_notifications_mode_enum"))] + pub struct CommunityNotificationsModeEnum; + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "community_visibility"))] pub struct CommunityVisibility; @@ -29,10 +33,18 @@ pub mod sql_types { #[diesel(postgres_type(name = "ltree"))] pub struct Ltree; + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "notification_type_enum"))] + pub struct NotificationTypeEnum; + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "post_listing_mode_enum"))] pub struct PostListingModeEnum; + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "post_notifications_mode_enum"))] + pub struct PostNotificationsModeEnum; + #[derive(diesel::query_builder::QueryId, diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "post_sort_type_enum"))] pub struct PostSortTypeEnum; @@ -156,16 +168,6 @@ diesel::table! { } } -diesel::table! { - comment_reply (id) { - id -> Int4, - recipient_id -> Int4, - comment_id -> Int4, - read -> Bool, - published_at -> Timestamptz, - } -} - diesel::table! { comment_report (id) { id -> Int4, @@ -238,6 +240,7 @@ diesel::table! { diesel::table! { use diesel::sql_types::*; use super::sql_types::CommunityFollowerState; + use super::sql_types::CommunityNotificationsModeEnum; community_actions (person_id, community_id) { community_id -> Int4, @@ -249,6 +252,7 @@ diesel::table! { became_moderator_at -> Nullable, received_ban_at -> Nullable, ban_expires_at -> Nullable, + notifications -> Nullable, } } @@ -347,17 +351,6 @@ diesel::table! { } } -diesel::table! { - inbox_combined (id) { - id -> Int4, - published_at -> Timestamptz, - comment_reply_id -> Nullable, - person_comment_mention_id -> Nullable, - person_post_mention_id -> Nullable, - private_message_id -> Nullable, - } -} - diesel::table! { instance (id) { id -> Int4, @@ -752,6 +745,22 @@ diesel::table! { } } +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::NotificationTypeEnum; + + notification (id) { + id -> Int4, + recipient_id -> Int4, + comment_id -> Nullable, + read -> Bool, + published_at -> Timestamptz, + kind -> NotificationTypeEnum, + post_id -> Nullable, + private_message_id -> Nullable, + } +} + diesel::table! { oauth_account (oauth_provider_id, local_user_id) { local_user_id -> Int4, @@ -838,16 +847,6 @@ diesel::table! { } } -diesel::table! { - person_comment_mention (id) { - id -> Int4, - recipient_id -> Int4, - comment_id -> Int4, - read -> Bool, - published_at -> Timestamptz, - } -} - diesel::table! { person_content_combined (id) { id -> Int4, @@ -868,16 +867,6 @@ diesel::table! { } } -diesel::table! { - person_post_mention (id) { - id -> Int4, - recipient_id -> Int4, - post_id -> Int4, - read -> Bool, - published_at -> Timestamptz, - } -} - diesel::table! { person_saved_combined (id) { id -> Int4, @@ -934,6 +923,9 @@ diesel::table! { } diesel::table! { + use diesel::sql_types::*; + use super::sql_types::PostNotificationsModeEnum; + post_actions (person_id, post_id) { post_id -> Int4, person_id -> Int4, @@ -944,6 +936,7 @@ diesel::table! { liked_at -> Nullable, like_score -> Nullable, hidden_at -> Nullable, + notifications -> Nullable, } } @@ -980,7 +973,6 @@ diesel::table! { recipient_id -> Int4, content -> Text, deleted -> Bool, - read -> Bool, published_at -> Timestamptz, updated_at -> Nullable, #[max_length = 255] @@ -1145,8 +1137,6 @@ diesel::joinable!(comment -> person (creator_id)); diesel::joinable!(comment -> post (post_id)); diesel::joinable!(comment_actions -> comment (comment_id)); diesel::joinable!(comment_actions -> person (person_id)); -diesel::joinable!(comment_reply -> comment (comment_id)); -diesel::joinable!(comment_reply -> person (recipient_id)); diesel::joinable!(comment_report -> comment (comment_id)); diesel::joinable!(community -> instance (instance_id)); diesel::joinable!(community_actions -> community (community_id)); @@ -1158,10 +1148,6 @@ diesel::joinable!(email_verification -> local_user (local_user_id)); diesel::joinable!(federation_allowlist -> instance (instance_id)); diesel::joinable!(federation_blocklist -> instance (instance_id)); diesel::joinable!(federation_queue_state -> instance (instance_id)); -diesel::joinable!(inbox_combined -> comment_reply (comment_reply_id)); -diesel::joinable!(inbox_combined -> person_comment_mention (person_comment_mention_id)); -diesel::joinable!(inbox_combined -> person_post_mention (person_post_mention_id)); -diesel::joinable!(inbox_combined -> private_message (private_message_id)); diesel::joinable!(instance_actions -> instance (instance_id)); diesel::joinable!(instance_actions -> person (person_id)); diesel::joinable!(local_image -> person (person_id)); @@ -1214,19 +1200,19 @@ diesel::joinable!(multi_community_entry -> community (community_id)); diesel::joinable!(multi_community_entry -> multi_community (multi_community_id)); diesel::joinable!(multi_community_follow -> multi_community (multi_community_id)); diesel::joinable!(multi_community_follow -> person (person_id)); +diesel::joinable!(notification -> comment (comment_id)); +diesel::joinable!(notification -> person (recipient_id)); +diesel::joinable!(notification -> post (post_id)); +diesel::joinable!(notification -> private_message (private_message_id)); diesel::joinable!(oauth_account -> local_user (local_user_id)); diesel::joinable!(oauth_account -> oauth_provider (oauth_provider_id)); diesel::joinable!(password_reset_request -> local_user (local_user_id)); diesel::joinable!(person -> instance (instance_id)); -diesel::joinable!(person_comment_mention -> comment (comment_id)); -diesel::joinable!(person_comment_mention -> person (recipient_id)); diesel::joinable!(person_content_combined -> comment (comment_id)); diesel::joinable!(person_content_combined -> post (post_id)); diesel::joinable!(person_liked_combined -> comment (comment_id)); diesel::joinable!(person_liked_combined -> person (person_id)); diesel::joinable!(person_liked_combined -> post (post_id)); -diesel::joinable!(person_post_mention -> person (recipient_id)); -diesel::joinable!(person_post_mention -> post (post_id)); diesel::joinable!(person_saved_combined -> comment (comment_id)); diesel::joinable!(person_saved_combined -> person (person_id)); diesel::joinable!(person_saved_combined -> post (post_id)); @@ -1265,7 +1251,6 @@ diesel::allow_tables_to_appear_in_same_query!( captcha_answer, comment, comment_actions, - comment_reply, comment_report, community, community_actions, @@ -1278,7 +1263,6 @@ diesel::allow_tables_to_appear_in_same_query!( federation_blocklist, federation_queue_state, image_details, - inbox_combined, instance, instance_actions, language, @@ -1305,15 +1289,14 @@ diesel::allow_tables_to_appear_in_same_query!( multi_community, multi_community_entry, multi_community_follow, + notification, oauth_account, oauth_provider, password_reset_request, person, person_actions, - person_comment_mention, person_content_combined, person_liked_combined, - person_post_mention, person_saved_combined, post, post_actions, diff --git a/crates/db_schema_setup/replaceable_schema/triggers.sql b/crates/db_schema_setup/replaceable_schema/triggers.sql index 6b1efae4c..3e8aa1b9d 100644 --- a/crates/db_schema_setup/replaceable_schema/triggers.sql +++ b/crates/db_schema_setup/replaceable_schema/triggers.sql @@ -613,33 +613,6 @@ CALL r.create_modlog_combined_trigger ('mod_remove_comment'); CALL r.create_modlog_combined_trigger ('mod_remove_community'); CALL r.create_modlog_combined_trigger ('mod_remove_post'); CALL r.create_modlog_combined_trigger ('mod_transfer_community'); --- Inbox: (replies, comment mentions, post mentions, and private_messages) -CREATE PROCEDURE r.create_inbox_combined_trigger (table_name text) -LANGUAGE plpgsql -AS $a$ -BEGIN - EXECUTE replace($b$ CREATE FUNCTION r.inbox_combined_thing_insert ( ) - RETURNS TRIGGER - LANGUAGE plpgsql - AS $$ - BEGIN - INSERT INTO inbox_combined (published_at, thing_id) - VALUES (NEW.published_at, NEW.id); - RETURN NEW; - END $$; - CREATE TRIGGER inbox_combined - AFTER INSERT ON thing - FOR EACH ROW - EXECUTE FUNCTION r.inbox_combined_thing_insert ( ); - $b$, - 'thing', - table_name); -END; -$a$; -CALL r.create_inbox_combined_trigger ('comment_reply'); -CALL r.create_inbox_combined_trigger ('person_comment_mention'); -CALL r.create_inbox_combined_trigger ('person_post_mention'); -CALL r.create_inbox_combined_trigger ('private_message'); -- Prevent using delete instead of uplete on action tables CREATE FUNCTION r.require_uplete () RETURNS TRIGGER diff --git a/crates/db_schema_setup/src/lib.rs b/crates/db_schema_setup/src/lib.rs index a51526827..f44b7830a 100644 --- a/crates/db_schema_setup/src/lib.rs +++ b/crates/db_schema_setup/src/lib.rs @@ -519,7 +519,7 @@ mod tests { } fn check_test_data(conn: &mut PgConnection) -> LemmyResult<()> { - use lemmy_db_schema_file::schema::{comment, comment_reply, community, person, post}; + use lemmy_db_schema_file::schema::{comment, community, notification, person, post}; // Check users let users: Vec<(i32, String, Option, String, String)> = person::table @@ -622,16 +622,16 @@ mod tests { assert_eq!(comments[1].6, 0); // Zero upvotes // Check comment replies - let replies: Vec<(i32, i32)> = comment_reply::table - .select((comment_reply::comment_id, comment_reply::recipient_id)) - .order_by(comment_reply::comment_id) + let replies: Vec<(Option, i32)> = notification::table + .select((notification::comment_id, notification::recipient_id)) + .order_by(notification::comment_id) .load(conn) .map_err(|e| anyhow!("Failed to read comment replies: {}", e))?; assert_eq!(replies.len(), 2); - assert_eq!(replies[0].0, TEST_COMMENT_ID_1); + assert_eq!(replies[0].0, Some(TEST_COMMENT_ID_1)); assert_eq!(replies[0].1, TEST_USER_ID_1); - assert_eq!(replies[1].0, TEST_COMMENT_ID_2); + assert_eq!(replies[1].0, Some(TEST_COMMENT_ID_2)); assert_eq!(replies[1].1, TEST_USER_ID_2); Ok(()) diff --git a/crates/db_views/comment/src/api.rs b/crates/db_views/comment/src/api.rs index 3700df468..5ea1be8ba 100644 --- a/crates/db_views/comment/src/api.rs +++ b/crates/db_views/comment/src/api.rs @@ -1,12 +1,5 @@ use crate::{CommentSlimView, CommentView}; -use lemmy_db_schema::newtypes::{ - CommentId, - CommunityId, - LanguageId, - LocalUserId, - PaginationCursor, - PostId, -}; +use lemmy_db_schema::newtypes::{CommentId, CommunityId, LanguageId, PaginationCursor, PostId}; use lemmy_db_schema_file::enums::{CommentSortType, ListingType}; use lemmy_db_views_vote::VoteView; use serde::{Deserialize, Serialize}; @@ -19,7 +12,6 @@ use serde_with::skip_serializing_none; /// A comment response. pub struct CommentResponse { pub comment_view: CommentView, - pub recipient_ids: Vec, } #[skip_serializing_none] diff --git a/crates/db_views/community/src/api.rs b/crates/db_views/community/src/api.rs index c02821891..f841b637e 100644 --- a/crates/db_views/community/src/api.rs +++ b/crates/db_views/community/src/api.rs @@ -4,7 +4,7 @@ use lemmy_db_schema::{ source::site::Site, CommunitySortType, }; -use lemmy_db_schema_file::enums::{CommunityVisibility, ListingType}; +use lemmy_db_schema_file::enums::{CommunityNotificationsMode, CommunityVisibility, ListingType}; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_db_views_person::PersonView; use serde::{Deserialize, Serialize}; @@ -364,3 +364,12 @@ pub struct FollowMultiCommunity { pub multi_community_id: MultiCommunityId, pub follow: bool, } + +#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] +/// Change notification settings for a community +pub struct UpdateCommunityNotifications { + pub community_id: CommunityId, + pub mode: CommunityNotificationsMode, +} diff --git a/crates/db_views/inbox_combined/src/impls.rs b/crates/db_views/inbox_combined/src/impls.rs deleted file mode 100644 index c82f2a847..000000000 --- a/crates/db_views/inbox_combined/src/impls.rs +++ /dev/null @@ -1,901 +0,0 @@ -use crate::{ - CommentReplyView, - InboxCombinedView, - InboxCombinedViewInternal, - PersonCommentMentionView, - PersonPostMentionView, -}; -use diesel::{ - dsl::not, - BoolExpressionMethods, - ExpressionMethods, - JoinOnDsl, - NullableExpressionMethods, - QueryDsl, - SelectableHelper, -}; -use diesel_async::RunQueryDsl; -use i_love_jesus::SortDirection; -use lemmy_db_schema::{ - aliases::{self}, - newtypes::{InstanceId, PaginationCursor, PersonId}, - source::combined::inbox::{inbox_combined_keys as key, InboxCombined}, - traits::{InternalToCombinedView, PaginationCursorBuilder}, - utils::{ - get_conn, - limit_fetch, - paginate, - queries::{ - community_join, - creator_community_actions_join, - creator_home_instance_actions_join, - creator_local_instance_actions_join, - creator_local_user_admin_join, - image_details_join, - my_comment_actions_join, - my_community_actions_join, - my_instance_actions_person_join, - my_local_user_admin_join, - my_person_actions_join, - my_post_actions_join, - }, - DbPool, - }, - InboxDataType, -}; -use lemmy_db_schema_file::schema::{ - comment, - comment_reply, - inbox_combined, - instance_actions, - person, - person_actions, - person_comment_mention, - person_post_mention, - post, - private_message, -}; -use lemmy_db_views_private_message::PrivateMessageView; -use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; - -impl InboxCombinedViewInternal { - #[diesel::dsl::auto_type(no_type_alias)] - fn joins(my_person_id: PersonId, local_instance_id: InstanceId) -> _ { - let item_creator = person::id; - let recipient_person = aliases::person1.field(person::id); - - let item_creator_join = person::table.on( - comment::creator_id - .eq(item_creator) - .or( - inbox_combined::person_post_mention_id - .is_not_null() - .and(post::creator_id.eq(item_creator)), - ) - .or(private_message::creator_id.eq(item_creator)), - ); - - let recipient_join = aliases::person1.on( - comment_reply::recipient_id - .eq(recipient_person) - .or(person_comment_mention::recipient_id.eq(recipient_person)) - .or(person_post_mention::recipient_id.eq(recipient_person)) - .or(private_message::recipient_id.eq(recipient_person)), - ); - - let comment_join = comment::table.on( - comment_reply::comment_id - .eq(comment::id) - .or(person_comment_mention::comment_id.eq(comment::id)) - // Filter out the deleted / removed - .and(not(comment::deleted)) - .and(not(comment::removed)), - ); - - let post_join = post::table.on( - person_post_mention::post_id - .eq(post::id) - .or(comment::post_id.eq(post::id)) - // Filter out the deleted / removed - .and(not(post::deleted)) - .and(not(post::removed)), - ); - - // This could be a simple join, but you need to check for deleted here - let private_message_join = private_message::table.on( - inbox_combined::private_message_id - .eq(private_message::id.nullable()) - .and(not(private_message::deleted)) - .and(not(private_message::removed)), - ); - - let my_community_actions_join: my_community_actions_join = - my_community_actions_join(Some(my_person_id)); - let my_post_actions_join: my_post_actions_join = my_post_actions_join(Some(my_person_id)); - let my_comment_actions_join: my_comment_actions_join = - my_comment_actions_join(Some(my_person_id)); - let my_local_user_admin_join: my_local_user_admin_join = - my_local_user_admin_join(Some(my_person_id)); - let my_instance_actions_person_join: my_instance_actions_person_join = - my_instance_actions_person_join(Some(my_person_id)); - let my_person_actions_join: my_person_actions_join = my_person_actions_join(Some(my_person_id)); - let creator_local_instance_actions_join: creator_local_instance_actions_join = - creator_local_instance_actions_join(local_instance_id); - - inbox_combined::table - .left_join(comment_reply::table) - .left_join(person_comment_mention::table) - .left_join(person_post_mention::table) - .left_join(private_message_join) - .left_join(comment_join) - .left_join(post_join) - .left_join(community_join()) - .inner_join(item_creator_join) - .inner_join(recipient_join) - .left_join(image_details_join()) - .left_join(creator_community_actions_join()) - .left_join(my_local_user_admin_join) - .left_join(creator_local_user_admin_join()) - .left_join(my_community_actions_join) - .left_join(my_instance_actions_person_join) - .left_join(creator_home_instance_actions_join()) - .left_join(creator_local_instance_actions_join) - .left_join(my_post_actions_join) - .left_join(my_person_actions_join) - .left_join(my_comment_actions_join) - } - - /// Gets the number of unread mentions - pub async fn get_unread_count( - pool: &mut DbPool<'_>, - my_person_id: PersonId, - local_instance_id: InstanceId, - show_bot_accounts: bool, - ) -> LemmyResult { - use diesel::dsl::count; - let conn = &mut get_conn(pool).await?; - - let recipient_person = aliases::person1.field(person::id); - - let unread_filter = comment_reply::read - .eq(false) - .or(person_comment_mention::read.eq(false)) - .or(person_post_mention::read.eq(false)) - // If its unread, I only want the messages to me - .or( - private_message::read - .eq(false) - .and(private_message::recipient_id.eq(my_person_id)), - ); - - let mut query = Self::joins(my_person_id, local_instance_id) - // Filter for your user - .filter(recipient_person.eq(my_person_id)) - // Filter unreads - .filter(unread_filter) - // Don't count replies from blocked users - .filter(person_actions::blocked_at.is_null()) - .filter(instance_actions::blocked_at.is_null()) - .select(count(inbox_combined::id)) - .into_boxed(); - - // These filters need to be kept in sync with the filters in queries().list() - if !show_bot_accounts { - query = query.filter(not(person::bot_account)); - } - - query - .first::(conn) - .await - .with_lemmy_type(LemmyErrorType::NotFound) - } -} - -impl PaginationCursorBuilder for InboxCombinedView { - type CursorData = InboxCombined; - - fn to_cursor(&self) -> PaginationCursor { - let (prefix, id) = match &self { - InboxCombinedView::CommentReply(v) => ('R', v.comment_reply.id.0), - InboxCombinedView::CommentMention(v) => ('C', v.person_comment_mention.id.0), - InboxCombinedView::PostMention(v) => ('P', v.person_post_mention.id.0), - InboxCombinedView::PrivateMessage(v) => ('M', v.private_message.id.0), - }; - PaginationCursor::new_single(prefix, id) - } - - async fn from_cursor( - cursor: &PaginationCursor, - pool: &mut DbPool<'_>, - ) -> LemmyResult { - let conn = &mut get_conn(pool).await?; - let pids = cursor.prefixes_and_ids(); - let (prefix, id) = pids - .as_slice() - .first() - .ok_or(LemmyErrorType::CouldntParsePaginationToken)?; - - let mut query = inbox_combined::table - .select(Self::CursorData::as_select()) - .into_boxed(); - - query = match prefix { - 'R' => query.filter(inbox_combined::comment_reply_id.eq(id)), - 'C' => query.filter(inbox_combined::person_comment_mention_id.eq(id)), - 'P' => query.filter(inbox_combined::person_post_mention_id.eq(id)), - 'M' => query.filter(inbox_combined::private_message_id.eq(id)), - _ => return Err(LemmyErrorType::CouldntParsePaginationToken.into()), - }; - let token = query.first(conn).await?; - - Ok(token) - } -} - -#[derive(Default)] -pub struct InboxCombinedQuery { - pub type_: Option, - pub unread_only: Option, - pub show_bot_accounts: Option, - pub cursor_data: Option, - pub page_back: Option, - pub limit: Option, - pub no_limit: Option, -} - -impl InboxCombinedQuery { - pub async fn list( - self, - pool: &mut DbPool<'_>, - my_person_id: PersonId, - local_instance_id: InstanceId, - ) -> LemmyResult> { - let conn = &mut get_conn(pool).await?; - - let item_creator = person::id; - let recipient_person = aliases::person1.field(person::id); - - let mut query = InboxCombinedViewInternal::joins(my_person_id, local_instance_id) - .select(InboxCombinedViewInternal::as_select()) - .into_boxed(); - - if !self.no_limit.unwrap_or_default() { - let limit = limit_fetch(self.limit)?; - query = query.limit(limit); - } - - // Filters - if self.unread_only.unwrap_or_default() { - query = query - // The recipient filter (IE only show replies to you) - .filter(recipient_person.eq(my_person_id)) - .filter( - comment_reply::read - .eq(false) - .or(person_comment_mention::read.eq(false)) - .or(person_post_mention::read.eq(false)) - // If its unread, I only want the messages to me - .or(private_message::read.eq(false)), - ); - } else { - // A special case for private messages: show messages FROM you also. - // Use a not-null checks to catch the others - query = query.filter( - inbox_combined::comment_reply_id - .is_not_null() - .and(recipient_person.eq(my_person_id)) - .or( - inbox_combined::person_comment_mention_id - .is_not_null() - .and(recipient_person.eq(my_person_id)), - ) - .or( - inbox_combined::person_post_mention_id - .is_not_null() - .and(recipient_person.eq(my_person_id)), - ) - .or( - inbox_combined::private_message_id.is_not_null().and( - recipient_person - .eq(my_person_id) - .or(item_creator.eq(my_person_id)), - ), - ), - ); - } - - if !(self.show_bot_accounts.unwrap_or_default()) { - query = query.filter(not(person::bot_account)); - }; - - // Dont show replies from blocked users or instances - query = query - .filter(person_actions::blocked_at.is_null()) - .filter(instance_actions::blocked_at.is_null()); - - if let Some(type_) = self.type_ { - query = match type_ { - InboxDataType::All => query, - InboxDataType::CommentReply => query.filter(inbox_combined::comment_reply_id.is_not_null()), - InboxDataType::CommentMention => { - query.filter(inbox_combined::person_comment_mention_id.is_not_null()) - } - InboxDataType::PostMention => { - query.filter(inbox_combined::person_post_mention_id.is_not_null()) - } - InboxDataType::PrivateMessage => { - query.filter(inbox_combined::private_message_id.is_not_null()) - } - } - } - - // Sorting by published - let paginated_query = paginate( - query, - SortDirection::Desc, - self.cursor_data, - None, - self.page_back, - ) - .then_order_by(key::published_at) - // Tie breaker - .then_order_by(key::id); - - let res = paginated_query - .load::(conn) - .await?; - - // Map the query results to the enum - let out = res - .into_iter() - .filter_map(InternalToCombinedView::map_to_enum) - .collect(); - - Ok(out) - } -} - -impl InternalToCombinedView for InboxCombinedViewInternal { - type CombinedView = InboxCombinedView; - - fn map_to_enum(self) -> Option { - // Use for a short alias - let v = self; - - if let (Some(comment_reply), Some(comment), Some(post), Some(community)) = ( - v.comment_reply, - v.comment.clone(), - v.post.clone(), - v.community.clone(), - ) { - Some(InboxCombinedView::CommentReply(CommentReplyView { - comment_reply, - comment, - recipient: v.item_recipient, - post, - community, - creator: v.item_creator, - community_actions: v.community_actions, - comment_actions: v.comment_actions, - person_actions: v.person_actions, - instance_actions: v.instance_actions, - creator_is_admin: v.item_creator_is_admin, - post_tags: v.post_tags, - can_mod: v.can_mod, - creator_banned: v.creator_banned, - creator_banned_from_community: v.creator_banned_from_community, - creator_is_moderator: v.creator_is_moderator, - })) - } else if let (Some(person_comment_mention), Some(comment), Some(post), Some(community)) = ( - v.person_comment_mention, - v.comment, - v.post.clone(), - v.community.clone(), - ) { - Some(InboxCombinedView::CommentMention( - PersonCommentMentionView { - person_comment_mention, - comment, - recipient: v.item_recipient, - post, - community, - creator: v.item_creator, - community_actions: v.community_actions, - comment_actions: v.comment_actions, - person_actions: v.person_actions, - instance_actions: v.instance_actions, - creator_is_admin: v.item_creator_is_admin, - can_mod: v.can_mod, - creator_banned: v.creator_banned, - creator_banned_from_community: v.creator_banned_from_community, - creator_is_moderator: v.creator_is_moderator, - }, - )) - } else if let (Some(person_post_mention), Some(post), Some(community)) = - (v.person_post_mention, v.post, v.community) - { - Some(InboxCombinedView::PostMention(PersonPostMentionView { - person_post_mention, - post, - community, - creator: v.item_creator, - recipient: v.item_recipient, - community_actions: v.community_actions, - person_actions: v.person_actions, - instance_actions: v.instance_actions, - post_actions: v.post_actions, - image_details: v.image_details, - creator_is_admin: v.item_creator_is_admin, - post_tags: v.post_tags, - can_mod: v.can_mod, - creator_banned: v.creator_banned, - creator_banned_from_community: v.creator_banned_from_community, - creator_is_moderator: v.creator_is_moderator, - })) - } else if let Some(private_message) = v.private_message { - Some(InboxCombinedView::PrivateMessage(PrivateMessageView { - private_message, - creator: v.item_creator, - recipient: v.item_recipient, - })) - } else { - None - } - } -} - -#[cfg(test)] -#[expect(clippy::indexing_slicing)] -mod tests { - use crate::{impls::InboxCombinedQuery, InboxCombinedView, InboxCombinedViewInternal}; - use lemmy_db_schema::{ - assert_length, - source::{ - comment::{Comment, CommentInsertForm}, - comment_reply::{CommentReply, CommentReplyInsertForm, CommentReplyUpdateForm}, - community::{Community, CommunityInsertForm}, - instance::{Instance, InstanceActions, InstanceBlockForm}, - person::{Person, PersonActions, PersonBlockForm, PersonInsertForm, PersonUpdateForm}, - person_comment_mention::{PersonCommentMention, PersonCommentMentionInsertForm}, - person_post_mention::{PersonPostMention, PersonPostMentionInsertForm}, - post::{Post, PostInsertForm}, - private_message::{PrivateMessage, PrivateMessageInsertForm}, - }, - traits::{Blockable, Crud}, - utils::{build_db_pool_for_tests, DbPool}, - InboxDataType, - }; - use lemmy_db_views_private_message::PrivateMessageView; - use lemmy_utils::error::LemmyResult; - use pretty_assertions::assert_eq; - use serial_test::serial; - - struct Data { - instance: Instance, - timmy: Person, - sara: Person, - jessica: Person, - timmy_post: Post, - jessica_post: Post, - timmy_comment: Comment, - sara_comment: Comment, - } - - async fn init_data(pool: &mut DbPool<'_>) -> LemmyResult { - let instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?; - - let timmy_form = PersonInsertForm::test_form(instance.id, "timmy_pcv"); - let timmy = Person::create(pool, &timmy_form).await?; - - let sara_form = PersonInsertForm::test_form(instance.id, "sara_pcv"); - let sara = Person::create(pool, &sara_form).await?; - - let jessica_form = PersonInsertForm::test_form(instance.id, "jessica_mrv"); - let jessica = Person::create(pool, &jessica_form).await?; - - let community_form = CommunityInsertForm::new( - instance.id, - "test community pcv".to_string(), - "nada".to_owned(), - "pubkey".to_string(), - ); - let community = Community::create(pool, &community_form).await?; - - let timmy_post_form = PostInsertForm::new("timmy post prv".into(), timmy.id, community.id); - let timmy_post = Post::create(pool, &timmy_post_form).await?; - - let jessica_post_form = - PostInsertForm::new("jessica post prv".into(), jessica.id, community.id); - let jessica_post = Post::create(pool, &jessica_post_form).await?; - - let timmy_comment_form = - CommentInsertForm::new(timmy.id, timmy_post.id, "timmy comment prv".into()); - let timmy_comment = Comment::create(pool, &timmy_comment_form, None).await?; - - let sara_comment_form = - CommentInsertForm::new(sara.id, timmy_post.id, "sara comment prv".into()); - let sara_comment = Comment::create(pool, &sara_comment_form, Some(&timmy_comment.path)).await?; - - Ok(Data { - instance, - timmy, - sara, - jessica, - timmy_post, - jessica_post, - timmy_comment, - sara_comment, - }) - } - - async fn setup_private_messages(data: &Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { - let sara_timmy_message_form = - PrivateMessageInsertForm::new(data.sara.id, data.timmy.id, "sara to timmy".into()); - PrivateMessage::create(pool, &sara_timmy_message_form).await?; - - let sara_jessica_message_form = - PrivateMessageInsertForm::new(data.sara.id, data.jessica.id, "sara to jessica".into()); - PrivateMessage::create(pool, &sara_jessica_message_form).await?; - - let timmy_sara_message_form = - PrivateMessageInsertForm::new(data.timmy.id, data.sara.id, "timmy to sara".into()); - PrivateMessage::create(pool, &timmy_sara_message_form).await?; - - let jessica_timmy_message_form = - PrivateMessageInsertForm::new(data.jessica.id, data.timmy.id, "jessica to timmy".into()); - PrivateMessage::create(pool, &jessica_timmy_message_form).await?; - - Ok(()) - } - - async fn cleanup(data: Data, pool: &mut DbPool<'_>) -> LemmyResult<()> { - Instance::delete(pool, data.instance.id).await?; - - Ok(()) - } - - #[tokio::test] - #[serial] - async fn replies() -> LemmyResult<()> { - let pool = &build_db_pool_for_tests(); - let pool = &mut pool.into(); - let data = init_data(pool).await?; - - // Sara replied to timmys comment, but lets create the row now - let form = CommentReplyInsertForm { - recipient_id: data.timmy.id, - comment_id: data.sara_comment.id, - read: None, - }; - let reply = CommentReply::create(pool, &form).await?; - - let timmy_unread_replies = - InboxCombinedViewInternal::get_unread_count(pool, data.timmy.id, data.instance.id, true) - .await?; - assert_eq!(1, timmy_unread_replies); - - let timmy_inbox = InboxCombinedQuery::default() - .list(pool, data.timmy.id, data.instance.id) - .await?; - assert_length!(1, timmy_inbox); - - if let InboxCombinedView::CommentReply(v) = &timmy_inbox[0] { - assert_eq!(data.sara_comment.id, v.comment_reply.comment_id); - assert_eq!(data.sara_comment.id, v.comment.id); - assert_eq!(data.timmy_post.id, v.post.id); - assert_eq!(data.sara.id, v.creator.id); - assert_eq!(data.timmy.id, v.recipient.id); - } else { - panic!("wrong type"); - } - - // Mark it as read - let form = CommentReplyUpdateForm { read: Some(true) }; - CommentReply::update(pool, reply.id, &form).await?; - - let timmy_unread_replies = - InboxCombinedViewInternal::get_unread_count(pool, data.timmy.id, data.instance.id, true) - .await?; - assert_eq!(0, timmy_unread_replies); - - let timmy_inbox_unread = InboxCombinedQuery { - unread_only: Some(true), - ..Default::default() - } - .list(pool, data.timmy.id, data.instance.id) - .await?; - assert_length!(0, timmy_inbox_unread); - - cleanup(data, pool).await?; - - Ok(()) - } - - #[tokio::test] - #[serial] - async fn mentions() -> LemmyResult<()> { - let pool = &build_db_pool_for_tests(); - let pool = &mut pool.into(); - let data = init_data(pool).await?; - - // Timmy mentions sara in a comment - let timmy_mention_sara_comment_form = PersonCommentMentionInsertForm { - recipient_id: data.sara.id, - comment_id: data.timmy_comment.id, - read: None, - }; - PersonCommentMention::create(pool, &timmy_mention_sara_comment_form).await?; - - // Jessica mentions sara in a post - let jessica_mention_sara_post_form = PersonPostMentionInsertForm { - recipient_id: data.sara.id, - post_id: data.jessica_post.id, - read: None, - }; - PersonPostMention::create(pool, &jessica_mention_sara_post_form).await?; - - // Test to make sure counts and blocks work correctly - let sara_unread_mentions = - InboxCombinedViewInternal::get_unread_count(pool, data.sara.id, data.instance.id, true) - .await?; - assert_eq!(2, sara_unread_mentions); - - let sara_inbox = InboxCombinedQuery::default() - .list(pool, data.sara.id, data.instance.id) - .await?; - assert_length!(2, sara_inbox); - - if let InboxCombinedView::PostMention(v) = &sara_inbox[0] { - assert_eq!(data.jessica_post.id, v.person_post_mention.post_id); - assert_eq!(data.jessica_post.id, v.post.id); - assert_eq!(data.jessica.id, v.creator.id); - assert_eq!(data.sara.id, v.recipient.id); - } else { - panic!("wrong type"); - } - - if let InboxCombinedView::CommentMention(v) = &sara_inbox[1] { - assert_eq!(data.timmy_comment.id, v.person_comment_mention.comment_id); - assert_eq!(data.timmy_comment.id, v.comment.id); - assert_eq!(data.timmy_post.id, v.post.id); - assert_eq!(data.timmy.id, v.creator.id); - assert_eq!(data.sara.id, v.recipient.id); - } else { - panic!("wrong type"); - } - - // Sara blocks timmy, and make sure these counts are now empty - let sara_blocks_timmy_form = PersonBlockForm::new(data.sara.id, data.timmy.id); - PersonActions::block(pool, &sara_blocks_timmy_form).await?; - - let sara_unread_mentions_after_block = - InboxCombinedViewInternal::get_unread_count(pool, data.sara.id, data.instance.id, true) - .await?; - assert_eq!(1, sara_unread_mentions_after_block); - - let sara_inbox_after_block = InboxCombinedQuery::default() - .list(pool, data.sara.id, data.instance.id) - .await?; - assert_length!(1, sara_inbox_after_block); - - // Make sure the comment mention which timmy made is the hidden one - assert!(matches!( - sara_inbox_after_block[0], - InboxCombinedView::PostMention(_) - )); - - // Unblock user so we can reuse the same person - PersonActions::unblock(pool, &sara_blocks_timmy_form).await?; - - // Test the type filter - let sara_inbox_post_mentions_only = InboxCombinedQuery { - type_: Some(InboxDataType::PostMention), - ..Default::default() - } - .list(pool, data.sara.id, data.instance.id) - .await?; - assert_length!(1, sara_inbox_post_mentions_only); - - assert!(matches!( - sara_inbox_post_mentions_only[0], - InboxCombinedView::PostMention(_) - )); - - // Turn Jessica into a bot account - let person_update_form = PersonUpdateForm { - bot_account: Some(true), - ..Default::default() - }; - Person::update(pool, data.jessica.id, &person_update_form).await?; - - // Make sure sara hides bots - let sara_unread_mentions_after_hide_bots = - InboxCombinedViewInternal::get_unread_count(pool, data.sara.id, data.instance.id, false) - .await?; - assert_eq!(1, sara_unread_mentions_after_hide_bots); - - let sara_inbox_after_hide_bots = InboxCombinedQuery::default() - .list(pool, data.sara.id, data.instance.id) - .await?; - assert_length!(1, sara_inbox_after_hide_bots); - - // Make sure the post mention which jessica made is the hidden one - assert!(matches!( - sara_inbox_after_hide_bots[0], - InboxCombinedView::CommentMention(_) - )); - - // Mark them all as read - PersonPostMention::mark_all_as_read(pool, data.sara.id).await?; - PersonCommentMention::mark_all_as_read(pool, data.sara.id).await?; - - // Make sure none come back - let sara_unread_mentions = - InboxCombinedViewInternal::get_unread_count(pool, data.sara.id, data.instance.id, false) - .await?; - assert_eq!(0, sara_unread_mentions); - - let sara_inbox_unread = InboxCombinedQuery { - unread_only: Some(true), - ..Default::default() - } - .list(pool, data.sara.id, data.instance.id) - .await?; - assert_length!(0, sara_inbox_unread); - - cleanup(data, pool).await?; - - Ok(()) - } - - /// A helper function to coerce to a private message type for tests - fn map_to_pm(inbox: &[InboxCombinedView]) -> Vec { - inbox - .iter() - // Filter map to collect private messages - .filter_map(|f| { - if let InboxCombinedView::PrivateMessage(v) = f { - Some(v) - } else { - None - } - }) - .cloned() - .collect::>() - } - - #[tokio::test] - #[serial] - async fn read_private_messages() -> LemmyResult<()> { - let pool = &build_db_pool_for_tests(); - let pool = &mut pool.into(); - let data = init_data(pool).await?; - setup_private_messages(&data, pool).await?; - - let timmy_messages = map_to_pm( - &InboxCombinedQuery::default() - .list(pool, data.timmy.id, data.instance.id) - .await?, - ); - - // The read even shows timmy's sent messages - assert_length!(3, &timmy_messages); - assert_eq!(timmy_messages[0].creator.id, data.jessica.id); - assert_eq!(timmy_messages[0].recipient.id, data.timmy.id); - assert_eq!(timmy_messages[1].creator.id, data.timmy.id); - assert_eq!(timmy_messages[1].recipient.id, data.sara.id); - assert_eq!(timmy_messages[2].creator.id, data.sara.id); - assert_eq!(timmy_messages[2].recipient.id, data.timmy.id); - - let timmy_unread = - InboxCombinedViewInternal::get_unread_count(pool, data.timmy.id, data.instance.id, false) - .await?; - assert_eq!(2, timmy_unread); - - let timmy_unread_messages = map_to_pm( - &InboxCombinedQuery { - unread_only: Some(true), - ..Default::default() - } - .list(pool, data.timmy.id, data.instance.id) - .await?, - ); - - // The unread hides timmy's sent messages - assert_length!(2, &timmy_unread_messages); - assert_eq!(timmy_unread_messages[0].creator.id, data.jessica.id); - assert_eq!(timmy_unread_messages[0].recipient.id, data.timmy.id); - assert_eq!(timmy_unread_messages[1].creator.id, data.sara.id); - assert_eq!(timmy_unread_messages[1].recipient.id, data.timmy.id); - - cleanup(data, pool).await?; - - Ok(()) - } - - #[tokio::test] - #[serial] - async fn ensure_private_message_person_block() -> LemmyResult<()> { - let pool = &build_db_pool_for_tests(); - let pool = &mut pool.into(); - let data = init_data(pool).await?; - setup_private_messages(&data, pool).await?; - - // Make sure blocks are working - let timmy_blocks_sara_form = PersonBlockForm::new(data.timmy.id, data.sara.id); - - let inserted_block = PersonActions::block(pool, &timmy_blocks_sara_form).await?; - - assert_eq!( - (data.timmy.id, data.sara.id, true), - ( - inserted_block.person_id, - inserted_block.target_id, - inserted_block.blocked_at.is_some() - ) - ); - - let timmy_messages = map_to_pm( - &InboxCombinedQuery { - unread_only: Some(true), - ..Default::default() - } - .list(pool, data.timmy.id, data.instance.id) - .await?, - ); - - assert_length!(1, &timmy_messages); - - let timmy_unread = - InboxCombinedViewInternal::get_unread_count(pool, data.timmy.id, data.instance.id, false) - .await?; - assert_eq!(1, timmy_unread); - - cleanup(data, pool).await?; - - Ok(()) - } - - #[tokio::test] - #[serial] - async fn ensure_private_message_instance_block() -> LemmyResult<()> { - let pool = &build_db_pool_for_tests(); - let pool = &mut pool.into(); - let data = init_data(pool).await?; - setup_private_messages(&data, pool).await?; - - // Make sure instance_blocks are working - let timmy_blocks_instance_form = InstanceBlockForm::new(data.timmy.id, data.sara.instance_id); - - let inserted_instance_block = InstanceActions::block(pool, &timmy_blocks_instance_form).await?; - - assert_eq!( - (data.timmy.id, data.sara.instance_id, true), - ( - inserted_instance_block.person_id, - inserted_instance_block.instance_id, - inserted_instance_block.blocked_at.is_some() - ) - ); - - let timmy_messages = map_to_pm( - &InboxCombinedQuery { - unread_only: Some(true), - ..Default::default() - } - .list(pool, data.timmy.id, data.instance.id) - .await?, - ); - - assert_length!(0, &timmy_messages); - - let timmy_unread = - InboxCombinedViewInternal::get_unread_count(pool, data.timmy.id, data.instance.id, false) - .await?; - assert_eq!(0, timmy_unread); - - cleanup(data, pool).await?; - - Ok(()) - } -} diff --git a/crates/db_views/inbox_combined/src/lib.rs b/crates/db_views/inbox_combined/src/lib.rs deleted file mode 100644 index 1ab865507..000000000 --- a/crates/db_views/inbox_combined/src/lib.rs +++ /dev/null @@ -1,233 +0,0 @@ -use lemmy_db_schema::{ - newtypes::PaginationCursor, - source::{ - combined::inbox::InboxCombined, - comment::{Comment, CommentActions}, - comment_reply::CommentReply, - community::{Community, CommunityActions}, - images::ImageDetails, - instance::InstanceActions, - person::{Person, PersonActions}, - person_comment_mention::PersonCommentMention, - person_post_mention::PersonPostMention, - post::{Post, PostActions}, - private_message::PrivateMessage, - tag::TagsView, - }, - InboxDataType, -}; -use lemmy_db_views_private_message::PrivateMessageView; -use serde::{Deserialize, Serialize}; -use serde_with::skip_serializing_none; -#[cfg(feature = "full")] -use { - diesel::{Queryable, Selectable}, - lemmy_db_schema::{ - utils::queries::{ - creator_banned, - creator_is_admin, - local_user_can_mod, - person1_select, - post_tags_fragment, - }, - utils::queries::{creator_banned_from_community, creator_is_moderator}, - Person1AliasAllColumnsTuple, - }, -}; - -pub mod api; -#[cfg(feature = "full")] -pub mod impls; - -#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] -#[cfg_attr(feature = "full", derive(Queryable, Selectable))] -#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] -/// A combined inbox view -pub struct InboxCombinedViewInternal { - #[cfg_attr(feature = "full", diesel(embed))] - pub inbox_combined: InboxCombined, - #[cfg_attr(feature = "full", diesel(embed))] - pub comment_reply: Option, - #[cfg_attr(feature = "full", diesel(embed))] - pub person_comment_mention: Option, - #[cfg_attr(feature = "full", diesel(embed))] - pub person_post_mention: Option, - #[cfg_attr(feature = "full", diesel(embed))] - pub private_message: Option, - #[cfg_attr(feature = "full", diesel(embed))] - pub comment: Option, - #[cfg_attr(feature = "full", diesel(embed))] - pub post: Option, - #[cfg_attr(feature = "full", diesel(embed))] - pub community: Option, - #[cfg_attr(feature = "full", diesel(embed))] - pub item_creator: Person, - #[cfg_attr(feature = "full", - diesel( - select_expression_type = Person1AliasAllColumnsTuple, - select_expression = person1_select() - ) - )] - pub item_recipient: Person, - #[cfg_attr(feature = "full", diesel(embed))] - pub image_details: Option, - #[cfg_attr(feature = "full", diesel(embed))] - pub community_actions: Option, - #[cfg_attr(feature = "full", diesel(embed))] - pub instance_actions: Option, - #[cfg_attr(feature = "full", diesel(embed))] - pub post_actions: Option, - #[cfg_attr(feature = "full", diesel(embed))] - pub person_actions: Option, - #[cfg_attr(feature = "full", diesel(embed))] - pub comment_actions: Option, - #[cfg_attr(feature = "full", - diesel( - select_expression = creator_is_admin() - ) - )] - pub item_creator_is_admin: bool, - #[cfg_attr(feature = "full", - diesel( - select_expression = post_tags_fragment() - ) - )] - pub post_tags: TagsView, - #[cfg_attr(feature = "full", - diesel( - select_expression = local_user_can_mod() - ) - )] - pub can_mod: bool, - #[cfg_attr(feature = "full", - diesel( - select_expression = creator_banned() - ) - )] - pub creator_banned: bool, - #[cfg_attr(feature = "full", - diesel( - select_expression = creator_is_moderator() - ) - )] - pub creator_is_moderator: bool, - #[cfg_attr(feature = "full", - diesel( - select_expression = creator_banned_from_community() - ) - )] - pub creator_banned_from_community: bool, -} - -#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] -#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts-rs", ts(export))] -// Use serde's internal tagging, to work easier with javascript libraries -#[serde(tag = "type_")] -pub enum InboxCombinedView { - CommentReply(CommentReplyView), - CommentMention(PersonCommentMentionView), - PostMention(PersonPostMentionView), - PrivateMessage(PrivateMessageView), -} -#[skip_serializing_none] -#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] -#[cfg_attr(feature = "full", derive(Queryable))] -#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] -#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] -/// A person comment mention view. -pub struct PersonCommentMentionView { - pub person_comment_mention: PersonCommentMention, - pub recipient: Person, - pub comment: Comment, - pub creator: Person, - pub post: Post, - pub community: Community, - pub community_actions: Option, - pub comment_actions: Option, - pub person_actions: Option, - pub instance_actions: Option, - pub creator_is_admin: bool, - pub can_mod: bool, - pub creator_banned: bool, - pub creator_is_moderator: bool, - pub creator_banned_from_community: bool, -} - -#[skip_serializing_none] -#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] -#[cfg_attr(feature = "full", derive(Queryable))] -#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] -#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] -/// A person post mention view. -pub struct PersonPostMentionView { - pub person_post_mention: PersonPostMention, - pub recipient: Person, - pub post: Post, - pub creator: Person, - pub community: Community, - pub image_details: Option, - pub community_actions: Option, - pub person_actions: Option, - pub post_actions: Option, - pub instance_actions: Option, - pub post_tags: TagsView, - pub creator_is_admin: bool, - pub can_mod: bool, - pub creator_banned: bool, - pub creator_is_moderator: bool, - pub creator_banned_from_community: bool, -} - -#[skip_serializing_none] -#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] -#[cfg_attr(feature = "full", derive(Queryable))] -#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] -#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] -/// A comment reply view. -pub struct CommentReplyView { - pub comment_reply: CommentReply, - pub recipient: Person, - pub comment: Comment, - pub creator: Person, - pub post: Post, - pub community: Community, - pub community_actions: Option, - pub comment_actions: Option, - pub person_actions: Option, - #[cfg_attr(feature = "full", diesel(embed))] - pub instance_actions: Option, - pub creator_is_admin: bool, - pub post_tags: TagsView, - pub can_mod: bool, - pub creator_banned: bool, - pub creator_is_moderator: bool, - pub creator_banned_from_community: bool, -} - -#[skip_serializing_none] -#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] -/// Get your inbox (replies, comment mentions, post mentions, and messages) -pub struct ListInbox { - pub type_: Option, - pub unread_only: Option, - pub page_cursor: Option, - pub page_back: Option, - pub limit: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] -/// Get your inbox (replies, comment mentions, post mentions, and messages) -pub struct ListInboxResponse { - pub inbox: Vec, - /// the pagination cursor to use to fetch the next page - pub next_page: Option, - pub prev_page: Option, -} diff --git a/crates/db_views/inbox_combined/Cargo.toml b/crates/db_views/notification/Cargo.toml similarity index 76% rename from crates/db_views/inbox_combined/Cargo.toml rename to crates/db_views/notification/Cargo.toml index 6d801f5d1..59f11f70b 100644 --- a/crates/db_views/inbox_combined/Cargo.toml +++ b/crates/db_views/notification/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "lemmy_db_views_inbox_combined" +name = "lemmy_db_views_notification" version.workspace = true edition.workspace = true description.workspace = true @@ -23,19 +23,16 @@ full = [ "i-love-jesus", "lemmy_db_schema/full", "lemmy_db_schema_file/full", - "lemmy_db_views_private_message/full", -] -ts-rs = [ - "dep:ts-rs", - "lemmy_db_schema/ts-rs", - "lemmy_db_views_private_message/ts-rs", ] +ts-rs = ["dep:ts-rs", "lemmy_db_schema/ts-rs"] [dependencies] -lemmy_db_views_private_message = { workspace = true } lemmy_db_schema = { workspace = true } lemmy_utils = { workspace = true, optional = true } lemmy_db_schema_file = { workspace = true } +lemmy_db_views_private_message = { workspace = true } +lemmy_db_views_post = { workspace = true } +lemmy_db_views_comment = { workspace = true } diesel = { workspace = true, optional = true } diesel-async = { workspace = true, optional = true } serde = { workspace = true } @@ -44,6 +41,3 @@ i-love-jesus = { workspace = true, optional = true } serde_with = { workspace = true } [dev-dependencies] -pretty_assertions = { workspace = true } -serial_test = { workspace = true } -tokio = { workspace = true } diff --git a/crates/db_views/inbox_combined/src/api.rs b/crates/db_views/notification/src/api.rs similarity index 57% rename from crates/db_views/inbox_combined/src/api.rs rename to crates/db_views/notification/src/api.rs index d03f8a262..96e77bdb2 100644 --- a/crates/db_views/inbox_combined/src/api.rs +++ b/crates/db_views/notification/src/api.rs @@ -1,9 +1,4 @@ -use lemmy_db_schema::newtypes::{ - CommentReplyId, - PersonCommentMentionId, - PersonPostMentionId, - PrivateMessageId, -}; +use lemmy_db_schema::newtypes::{NotificationId, PrivateMessageId}; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -26,26 +21,8 @@ pub struct GetUnreadRegistrationApplicationCountResponse { #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Mark a comment reply as read. -pub struct MarkCommentReplyAsRead { - pub comment_reply_id: CommentReplyId, - pub read: bool, -} - -#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] -/// Mark a person mention as read. -pub struct MarkPersonCommentMentionAsRead { - pub person_comment_mention_id: PersonCommentMentionId, - pub read: bool, -} - -#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] -#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] -/// Mark a person mention as read. -pub struct MarkPersonPostMentionAsRead { - pub person_post_mention_id: PersonPostMentionId, +pub struct MarkNotificationAsRead { + pub notification_id: NotificationId, pub read: bool, } diff --git a/crates/db_views/notification/src/impls.rs b/crates/db_views/notification/src/impls.rs new file mode 100644 index 000000000..9884c1f00 --- /dev/null +++ b/crates/db_views/notification/src/impls.rs @@ -0,0 +1,337 @@ +use crate::{CommentView, NotificationData, NotificationView, NotificationViewInternal}; +use diesel::{ + dsl::not, + BoolExpressionMethods, + ExpressionMethods, + JoinOnDsl, + NullableExpressionMethods, + QueryDsl, + SelectableHelper, +}; +use diesel_async::RunQueryDsl; +use i_love_jesus::SortDirection; +use lemmy_db_schema::{ + aliases, + newtypes::PaginationCursor, + source::{ + notification::{notification_keys, Notification}, + person::Person, + }, + traits::PaginationCursorBuilder, + utils::{ + get_conn, + limit_fetch, + paginate, + queries::{ + community_join, + creator_community_actions_join, + creator_home_instance_actions_join, + creator_local_instance_actions_join, + creator_local_user_admin_join, + image_details_join, + my_comment_actions_join, + my_community_actions_join, + my_instance_actions_person_join, + my_local_user_admin_join, + my_person_actions_join, + my_post_actions_join, + }, + DbPool, + }, + NotificationDataType, +}; +use lemmy_db_schema_file::{ + enums::NotificationTypes, + schema::{ + comment, + instance_actions, + notification, + person, + person_actions, + post, + private_message, + }, +}; +use lemmy_db_views_post::PostView; +use lemmy_db_views_private_message::PrivateMessageView; +use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; + +impl NotificationView { + #[diesel::dsl::auto_type(no_type_alias)] + fn joins(my_person: &Person) -> _ { + let item_creator = person::id; + let recipient_person = aliases::person1.field(person::id); + + let item_creator_join = person::table.on( + comment::creator_id + .eq(item_creator) + .or( + notification::post_id + .is_not_null() + .and(post::creator_id.eq(item_creator)), + ) + .or(private_message::creator_id.eq(item_creator)), + ); + + let recipient_join = aliases::person1.on(notification::recipient_id.eq(recipient_person)); + + let comment_join = comment::table.on( + notification::comment_id + .eq(comment::id.nullable()) + // Filter out the deleted / removed + .and(not(comment::deleted)) + .and(not(comment::removed)), + ); + + let post_join = post::table.on( + notification::post_id + .eq(post::id.nullable()) + .or(comment::post_id.eq(post::id)) + // Filter out the deleted / removed + .and(not(post::deleted)) + .and(not(post::removed)), + ); + + // This could be a simple join, but you need to check for deleted here + let private_message_join = private_message::table.on( + notification::private_message_id + .eq(private_message::id.nullable()) + .and(not(private_message::deleted)) + .and(not(private_message::removed)), + ); + + let my_community_actions_join: my_community_actions_join = + my_community_actions_join(Some(my_person.id)); + let my_post_actions_join: my_post_actions_join = my_post_actions_join(Some(my_person.id)); + let my_comment_actions_join: my_comment_actions_join = + my_comment_actions_join(Some(my_person.id)); + let my_local_user_admin_join: my_local_user_admin_join = + my_local_user_admin_join(Some(my_person.id)); + let my_instance_actions_person_join: my_instance_actions_person_join = + my_instance_actions_person_join(Some(my_person.id)); + let my_person_actions_join: my_person_actions_join = my_person_actions_join(Some(my_person.id)); + let creator_local_instance_actions_join: creator_local_instance_actions_join = + creator_local_instance_actions_join(my_person.instance_id); + + notification::table + .left_join(private_message_join) + .left_join(comment_join) + .left_join(post_join) + .left_join(community_join()) + .inner_join(item_creator_join) + .inner_join(recipient_join) + .left_join(image_details_join()) + .left_join(creator_community_actions_join()) + .left_join(my_local_user_admin_join) + .left_join(creator_local_user_admin_join()) + .left_join(my_community_actions_join) + .left_join(my_instance_actions_person_join) + .left_join(creator_home_instance_actions_join()) + .left_join(creator_local_instance_actions_join) + .left_join(my_post_actions_join) + .left_join(my_person_actions_join) + .left_join(my_comment_actions_join) + } + + /// Gets the number of unread mentions + pub async fn get_unread_count( + pool: &mut DbPool<'_>, + my_person: &Person, + show_bot_accounts: bool, + ) -> LemmyResult { + use diesel::dsl::count; + let conn = &mut get_conn(pool).await?; + + let unread_filter = notification::read.eq(false); + + let mut query = Self::joins(my_person) + // Filter for your user + .filter(notification::recipient_id.eq(my_person.id)) + // Filter unreads + .filter(unread_filter) + // Don't count replies from blocked users + .filter(person_actions::blocked_at.is_null()) + .filter(instance_actions::blocked_at.is_null()) + .select(count(notification::id)) + .into_boxed(); + + // These filters need to be kept in sync with the filters in queries().list() + if !show_bot_accounts { + query = query.filter(not(person::bot_account)); + } + + query + .first::(conn) + .await + .with_lemmy_type(LemmyErrorType::NotFound) + } +} + +impl PaginationCursorBuilder for NotificationView { + type CursorData = Notification; + + fn to_cursor(&self) -> PaginationCursor { + PaginationCursor(self.notification.id.0.to_string()) + } + + async fn from_cursor( + cursor: &PaginationCursor, + pool: &mut DbPool<'_>, + ) -> LemmyResult { + let conn = &mut get_conn(pool).await?; + let id: i32 = cursor.0.parse()?; + let query = notification::table + .select(Self::CursorData::as_select()) + .filter(notification::id.eq(id)); + let token = query.first(conn).await?; + + Ok(token) + } +} + +#[derive(Default)] +pub struct NotificationQuery { + pub type_: Option, + pub unread_only: Option, + pub show_bot_accounts: Option, + pub cursor_data: Option, + pub page_back: Option, + pub limit: Option, + pub no_limit: Option, +} + +impl NotificationQuery { + pub async fn list( + self, + pool: &mut DbPool<'_>, + my_person: &Person, + ) -> LemmyResult> { + let conn = &mut get_conn(pool).await?; + + let mut query = NotificationView::joins(my_person) + .select(NotificationViewInternal::as_select()) + .into_boxed(); + + if !self.no_limit.unwrap_or_default() { + let limit = limit_fetch(self.limit)?; + query = query.limit(limit); + } + + // Filters + if self.unread_only.unwrap_or_default() { + query = query + // The recipient filter (IE only show replies to you) + .filter(notification::recipient_id.eq(my_person.id)) + .filter(notification::read.eq(false)); + } else { + // A special case for private messages: show messages FROM you also. + // Use a not-null checks to catch the others + query = query.filter( + notification::recipient_id.eq(my_person.id).or( + notification::private_message_id.is_not_null().and( + notification::recipient_id + .eq(my_person.id) + .or(person::id.eq(my_person.id)), + ), + ), + ); + } + + if !(self.show_bot_accounts.unwrap_or_default()) { + query = query.filter(not(person::bot_account)); + }; + + // Dont show replies from blocked users or instances + query = query + .filter(person_actions::blocked_at.is_null()) + .filter(instance_actions::blocked_at.is_null()); + + if let Some(type_) = self.type_ { + query = match type_ { + NotificationDataType::All => query, + NotificationDataType::Reply => { + query.filter(notification::kind.eq(NotificationTypes::Reply)) + } + NotificationDataType::Mention => { + query.filter(notification::kind.eq(NotificationTypes::Mention)) + } + NotificationDataType::PrivateMessage => { + query.filter(notification::kind.eq(NotificationTypes::PrivateMessage)) + } + NotificationDataType::Subscribed => { + query.filter(notification::kind.eq(NotificationTypes::Subscribed)) + } + } + } + + // Sorting by published + let paginated_query = paginate( + query, + SortDirection::Desc, + self.cursor_data, + None, + self.page_back, + ) + .then_order_by(notification_keys::published_at) + // Tie breaker + .then_order_by(notification_keys::id); + + let res = paginated_query + .load::(conn) + .await?; + + Ok(res.into_iter().filter_map(map_to_enum).collect()) + } +} + +fn map_to_enum(v: NotificationViewInternal) -> Option { + let data = if let (Some(comment), Some(post), Some(community)) = + (v.comment, v.post.clone(), v.community.clone()) + { + NotificationData::Comment(CommentView { + comment, + post, + community, + creator: v.creator, + community_actions: v.community_actions, + instance_actions: v.instance_actions, + person_actions: v.person_actions, + comment_actions: v.comment_actions, + creator_is_admin: v.creator_is_admin, + post_tags: v.post_tags, + can_mod: v.can_mod, + creator_banned: v.creator_banned, + creator_is_moderator: v.creator_is_moderator, + creator_banned_from_community: v.creator_banned_from_community, + }) + } else if let (Some(post), Some(community)) = (v.post, v.community) { + NotificationData::Post(PostView { + post, + community, + creator: v.creator, + image_details: v.image_details, + community_actions: v.community_actions, + instance_actions: v.instance_actions, + post_actions: v.post_actions, + person_actions: v.person_actions, + creator_is_admin: v.creator_is_admin, + tags: v.post_tags, + can_mod: v.can_mod, + creator_banned: v.creator_banned, + creator_is_moderator: v.creator_is_moderator, + creator_banned_from_community: v.creator_banned_from_community, + }) + } else if let Some(private_message) = v.private_message { + NotificationData::PrivateMessage(PrivateMessageView { + private_message, + creator: v.creator, + recipient: v.recipient, + }) + } else { + return None; + }; + Some(NotificationView { + notification: v.notification, + data, + }) +} diff --git a/crates/db_views/notification/src/lib.rs b/crates/db_views/notification/src/lib.rs new file mode 100644 index 000000000..4a9eddb33 --- /dev/null +++ b/crates/db_views/notification/src/lib.rs @@ -0,0 +1,149 @@ +use lemmy_db_schema::{ + newtypes::PaginationCursor, + source::{ + comment::{Comment, CommentActions}, + community::{Community, CommunityActions}, + images::ImageDetails, + instance::InstanceActions, + notification::Notification, + person::{Person, PersonActions}, + post::{Post, PostActions}, + private_message::PrivateMessage, + tag::TagsView, + }, + NotificationDataType, +}; +use lemmy_db_views_comment::CommentView; +use lemmy_db_views_post::PostView; +use lemmy_db_views_private_message::PrivateMessageView; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +#[cfg(feature = "full")] +use { + diesel::{Queryable, Selectable}, + lemmy_db_schema::{ + utils::queries::person1_select, + utils::queries::{creator_banned, creator_is_admin, local_user_can_mod, post_tags_fragment}, + utils::queries::{creator_banned_from_community, creator_is_moderator}, + Person1AliasAllColumnsTuple, + }, +}; + +pub mod api; +#[cfg(feature = "full")] +pub mod impls; + +#[derive(Clone)] +#[cfg_attr(feature = "full", derive(Queryable, Selectable))] +#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] +struct NotificationViewInternal { + #[cfg_attr(feature = "full", diesel(embed))] + notification: Notification, + #[cfg_attr(feature = "full", diesel(embed))] + private_message: Option, + #[cfg_attr(feature = "full", diesel(embed))] + comment: Option, + #[cfg_attr(feature = "full", diesel(embed))] + post: Option, + #[cfg_attr(feature = "full", diesel(embed))] + community: Option, + #[cfg_attr(feature = "full", diesel(embed))] + creator: Person, + #[cfg_attr(feature = "full", + diesel( + select_expression_type = Person1AliasAllColumnsTuple, + select_expression = person1_select() + ) + )] + recipient: Person, + #[cfg_attr(feature = "full", diesel(embed))] + image_details: Option, + #[cfg_attr(feature = "full", diesel(embed))] + community_actions: Option, + #[cfg_attr(feature = "full", diesel(embed))] + instance_actions: Option, + #[cfg_attr(feature = "full", diesel(embed))] + post_actions: Option, + #[cfg_attr(feature = "full", diesel(embed))] + person_actions: Option, + #[cfg_attr(feature = "full", diesel(embed))] + comment_actions: Option, + #[cfg_attr(feature = "full", + diesel( + select_expression = creator_is_admin() + ) + )] + creator_is_admin: bool, + #[cfg_attr(feature = "full", + diesel( + select_expression = post_tags_fragment() + ) + )] + post_tags: TagsView, + #[cfg_attr(feature = "full", + diesel( + select_expression = local_user_can_mod() + ) + )] + can_mod: bool, + #[cfg_attr(feature = "full", + diesel( + select_expression = creator_banned() + ) + )] + creator_banned: bool, + #[cfg_attr(feature = "full", + diesel( + select_expression = creator_is_moderator() + ) + )] + creator_is_moderator: bool, + #[cfg_attr(feature = "full", + diesel( + select_expression = creator_banned_from_community() + ) + )] + creator_banned_from_community: bool, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts-rs", ts(export))] +pub struct NotificationView { + pub notification: Notification, + pub data: NotificationData, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts-rs", ts(export))] +#[serde(tag = "type_")] +pub enum NotificationData { + Comment(CommentView), + Post(PostView), + PrivateMessage(PrivateMessageView), +} + +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] +/// Get your inbox (replies, comment mentions, post mentions, and messages) +pub struct ListNotifications { + pub type_: Option, + pub unread_only: Option, + pub page_cursor: Option, + pub page_back: Option, + pub limit: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] +/// Get your inbox (replies, comment mentions, post mentions, and messages) +pub struct ListNotificationsResponse { + pub notifications: Vec, + /// the pagination cursor to use to fetch the next page + pub next_page: Option, + pub prev_page: Option, +} diff --git a/crates/db_views/post/src/api.rs b/crates/db_views/post/src/api.rs index d3b4bc1d8..cc715d09a 100644 --- a/crates/db_views/post/src/api.rs +++ b/crates/db_views/post/src/api.rs @@ -12,7 +12,7 @@ use lemmy_db_schema::{ }, PostFeatureType, }; -use lemmy_db_schema_file::enums::{ListingType, PostSortType}; +use lemmy_db_schema_file::enums::{ListingType, PostNotificationsMode, PostSortType}; use lemmy_db_views_community::CommunityView; use lemmy_db_views_vote::VoteView; use serde::{Deserialize, Serialize}; @@ -93,6 +93,15 @@ pub struct FeaturePost { pub feature_type: PostFeatureType, } +#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] +#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] +/// Change notification settings for a post +pub struct UpdatePostNotifications { + pub post_id: PostId, + pub mode: PostNotificationsMode, +} + #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] #[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))] diff --git a/crates/db_views/site/src/api.rs b/crates/db_views/site/src/api.rs index e1dd272f3..f42fca0bf 100644 --- a/crates/db_views/site/src/api.rs +++ b/crates/db_views/site/src/api.rs @@ -745,7 +745,7 @@ pub struct UserSettingsBackup { #[cfg_attr(feature = "ts-rs", ts(optional_fields, export))] /// Your exported data. pub struct ExportDataResponse { - pub inbox: Vec, + pub notifications: Vec, pub content: Vec, pub read_posts: Vec, pub liked: Vec, diff --git a/crates/email/src/notifications.rs b/crates/email/src/notifications.rs index 50bb30b0f..ac8f2c865 100644 --- a/crates/email/src/notifications.rs +++ b/crates/email/src/notifications.rs @@ -1,7 +1,7 @@ use crate::{inbox_link, send_email, user_language}; use lemmy_db_schema::{ newtypes::DbUrl, - source::{comment::Comment, person::Person, post::Post}, + source::{comment::Comment, community::Community, person::Person, post::Post}, }; use lemmy_db_views_local_user::LocalUserView; use lemmy_utils::{ @@ -30,69 +30,96 @@ pub async fn send_mention_email( .await } -pub async fn send_comment_reply_email( - parent_user_view: &LocalUserView, - comment: &Comment, - person: &Person, - parent_comment: &Comment, +pub async fn send_post_subscribed_email( + user_view: &LocalUserView, post: &Post, + comment: &Comment, + link: DbUrl, settings: &Settings, -) -> LemmyResult<()> { +) { let inbox_link = inbox_link(settings); - let lang = user_language(parent_user_view); + let lang = user_language(user_view); let content = markdown_to_html(&comment.content); send_email_to_user( - parent_user_view, - &lang.notification_comment_reply_subject(&person.name), - &lang.notification_comment_reply_body( - comment.local_url(settings)?, - &content, - &inbox_link, - &parent_comment.content, - &post.name, - &person.name, - ), + user_view, + &lang.notification_post_subscribed_subject(&post.name), + &lang.notification_post_subscribed_body(&content, &link, inbox_link), settings, ) - .await; - Ok(()) + .await } -pub async fn send_post_reply_email( +pub async fn send_community_subscribed_email( + user_view: &LocalUserView, + post: &Post, + community: &Community, + link: DbUrl, + settings: &Settings, +) { + let inbox_link = inbox_link(settings); + let lang = user_language(user_view); + let content = post + .body + .as_ref() + .map(|b| markdown_to_html(b)) + .unwrap_or_default(); + send_email_to_user( + user_view, + &lang.notification_community_subscribed_subject(&post.name, &community.title), + &lang.notification_community_subscribed_body(&content, &link, inbox_link), + settings, + ) + .await +} + +pub async fn send_reply_email( parent_user_view: &LocalUserView, comment: &Comment, person: &Person, + parent_comment: &Option, post: &Post, settings: &Settings, ) -> LemmyResult<()> { let inbox_link = inbox_link(settings); let lang = user_language(parent_user_view); let content = markdown_to_html(&comment.content); - send_email_to_user( - parent_user_view, - &lang.notification_post_reply_subject(&person.name), - &lang.notification_post_reply_body( - comment.local_url(settings)?, - &content, - &inbox_link, - &post.name, - &person.name, - ), - settings, - ) - .await; + let (subject, body) = if let Some(parent_comment) = parent_comment { + ( + lang.notification_comment_reply_subject(&person.name), + lang.notification_comment_reply_body( + comment.local_url(settings)?, + &content, + &inbox_link, + &parent_comment.content, + &post.name, + &person.name, + ), + ) + } else { + ( + lang.notification_post_reply_subject(&person.name), + lang.notification_post_reply_body( + comment.local_url(settings)?, + &content, + &inbox_link, + &post.name, + &person.name, + ), + ) + }; + send_email_to_user(parent_user_view, &subject, &body, settings).await; Ok(()) } pub async fn send_private_message_email( - sender: &LocalUserView, + sender: &Person, local_recipient: &LocalUserView, content: &str, settings: &Settings, ) { let inbox_link = inbox_link(settings); let lang = user_language(local_recipient); - let sender_name = &sender.person.name; + let sender_name = &sender.name; let content = markdown_to_html(content); send_email_to_user( local_recipient, diff --git a/crates/email/translations b/crates/email/translations index 7debe4149..72c9cc342 160000 --- a/crates/email/translations +++ b/crates/email/translations @@ -1 +1 @@ -Subproject commit 7debe41492de3f04403c9c78ced9697be199e394 +Subproject commit 72c9cc342b339779cd6d61a8e3349aeff5cad2ff diff --git a/crates/routes/Cargo.toml b/crates/routes/Cargo.toml index 356f9f81e..b69cd8bab 100644 --- a/crates/routes/Cargo.toml +++ b/crates/routes/Cargo.toml @@ -25,7 +25,7 @@ lemmy_db_views_community = { workspace = true, features = ["full"] } lemmy_db_views_post = { workspace = true, features = ["full"] } lemmy_db_views_local_image = { workspace = true, features = ["full"] } lemmy_db_views_local_user = { workspace = true, features = ["full"] } -lemmy_db_views_inbox_combined = { workspace = true, features = ["full"] } +lemmy_db_views_notification = { workspace = true, features = ["full"] } lemmy_db_views_modlog_combined = { workspace = true, features = ["full"] } lemmy_db_views_person_content_combined = { workspace = true, features = [ "full", diff --git a/crates/routes/src/feeds.rs b/crates/routes/src/feeds.rs index f8d5a77b4..4c87a2add 100644 --- a/crates/routes/src/feeds.rs +++ b/crates/routes/src/feeds.rs @@ -11,8 +11,8 @@ use lemmy_db_schema::{ PersonContentType, }; use lemmy_db_schema_file::enums::{ListingType, PostSortType}; -use lemmy_db_views_inbox_combined::{impls::InboxCombinedQuery, InboxCombinedView}; use lemmy_db_views_modlog_combined::{impls::ModlogCombinedQuery, ModlogCombinedView}; +use lemmy_db_views_notification::{impls::NotificationQuery, NotificationData, NotificationView}; use lemmy_db_views_person_content_combined::impls::PersonContentCombinedQuery; use lemmy_db_views_post::{impls::PostQuery, PostView}; use lemmy_db_views_site::SiteView; @@ -321,22 +321,20 @@ async fn get_feed_front( async fn get_feed_inbox(context: &LemmyContext, jwt: &str) -> LemmyResult { let site_view = SiteView::read_local(&mut context.pool()).await?; - let local_instance_id = site_view.site.instance_id; let local_user = local_user_view_from_jwt(jwt, context).await?; - let my_person_id = local_user.person.id; let show_bot_accounts = Some(local_user.local_user.show_bot_accounts); check_private_instance(&Some(local_user.clone()), &site_view.local_site)?; - let inbox = InboxCombinedQuery { + let notifications = NotificationQuery { show_bot_accounts, ..Default::default() } - .list(&mut context.pool(), my_person_id, local_instance_id) + .list(&mut context.pool(), &local_user.person) .await?; let protocol_and_hostname = context.settings().get_protocol_and_hostname(); - let items = create_reply_and_mention_items(inbox, context)?; + let items = create_reply_and_mention_items(notifications, context)?; let mut channel = Channel { namespaces: RSS_NAMESPACE.clone(), @@ -387,49 +385,39 @@ async fn get_feed_modlog(context: &LemmyContext, jwt: &str) -> LemmyResult, + inbox: Vec, context: &LemmyContext, ) -> LemmyResult> { let reply_items: Vec = inbox .iter() - .map(|r| match r { - InboxCombinedView::CommentReply(v) => { - let reply_url = v.comment.local_url(context.settings())?; + .map(|v| match &v.data { + NotificationData::Post(post) => { + let mention_url = post.post.local_url(context.settings())?; build_item( - &v.creator, - &v.comment.published_at, + &post.creator, + &post.post.published_at, + mention_url.as_str(), + &post.post.body.clone().unwrap_or_default(), + context.settings(), + ) + } + NotificationData::Comment(comment) => { + let reply_url = comment.comment.local_url(context.settings())?; + build_item( + &comment.creator, + &comment.comment.published_at, reply_url.as_str(), - &v.comment.content, + &comment.comment.content, context.settings(), ) } - InboxCombinedView::CommentMention(v) => { - let mention_url = v.comment.local_url(context.settings())?; - build_item( - &v.creator, - &v.comment.published_at, - mention_url.as_str(), - &v.comment.content, - context.settings(), - ) - } - InboxCombinedView::PostMention(v) => { - let mention_url = v.post.local_url(context.settings())?; - build_item( - &v.creator, - &v.post.published_at, - mention_url.as_str(), - &v.post.body.clone().unwrap_or_default(), - context.settings(), - ) - } - InboxCombinedView::PrivateMessage(v) => { + NotificationData::PrivateMessage(pm) => { let inbox_url = format!("{}/inbox", context.settings().get_protocol_and_hostname()); build_item( - &v.creator, - &v.private_message.published_at, + &pm.creator, + &pm.private_message.published_at, &inbox_url, - &v.private_message.content, + &pm.private_message.content, context.settings(), ) } diff --git a/crates/utils/src/error.rs b/crates/utils/src/error.rs index 5a825850a..80c5b2190 100644 --- a/crates/utils/src/error.rs +++ b/crates/utils/src/error.rs @@ -106,10 +106,8 @@ pub enum LemmyErrorType { CouldntUpdateReadComments, CouldntHidePost, CouldntUpdateCommunity, - CouldntCreatePersonCommentMention, - CouldntUpdatePersonCommentMention, - CouldntCreatePersonPostMention, - CouldntUpdatePersonPostMention, + CouldntCreateNotification, + CouldntUpdateNotification, CouldntCreatePost, CouldntCreatePrivateMessage, CouldntUpdatePrivateMessage, @@ -160,9 +158,6 @@ pub enum LemmyErrorType { CouldntParsePaginationToken, PluginError(String), InvalidFetchLimit, - CouldntCreateCommentReply, - CouldntUpdateCommentReply, - CouldntMarkCommentReplyAsRead, CouldntCreateEmoji, CouldntUpdateEmoji, CouldntCreatePerson, diff --git a/migrations/2025-07-17-103657_post-or-comment-notification/down.sql b/migrations/2025-07-17-103657_post-or-comment-notification/down.sql new file mode 100644 index 000000000..229540e10 --- /dev/null +++ b/migrations/2025-07-17-103657_post-or-comment-notification/down.sql @@ -0,0 +1,140 @@ +CREATE TABLE person_post_mention ( + id serial PRIMARY KEY, + recipient_id int REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + post_id int REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + read bool NOT NULL DEFAULT FALSE, + published_at timestamptz DEFAULT now() NOT NULL +); + +CREATE TABLE person_mention ( + id serial PRIMARY KEY, + recipient_id int REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + comment_id int REFERENCES comment (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + read bool NOT NULL DEFAULT FALSE, + published_at timestamptz DEFAULT now() NOT NULL, + UNIQUE (recipient_id, comment_id) +); + +ALTER TABLE person_mention RENAME TO person_comment_mention; + +CREATE TABLE comment_reply ( + id serial PRIMARY KEY, + recipient_id int REFERENCES person (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + comment_id int REFERENCES comment (id) ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + read bool NOT NULL DEFAULT FALSE, + published_at timestamptz DEFAULT now() NOT NULL, + UNIQUE (recipient_id, comment_id) +); + +CREATE TABLE inbox_combined ( + id serial PRIMARY KEY, + comment_reply_id int REFERENCES comment_reply (id) ON UPDATE CASCADE ON DELETE CASCADE UNIQUE, + person_comment_mention_id int REFERENCES person_comment_mention (id) ON UPDATE CASCADE ON DELETE CASCADE UNIQUE, + person_post_mention_id int REFERENCES person_post_mention (id) ON UPDATE CASCADE ON DELETE CASCADE UNIQUE, + private_message_id int REFERENCES private_message (id) ON UPDATE CASCADE ON DELETE CASCADE UNIQUE, + published_at timestamptz NOT NULL +); + +ALTER TABLE private_message + ADD COLUMN read bool DEFAULT FALSE NOT NULL; + +-- copy back data to person_post_mention table +INSERT INTO person_post_mention (recipient_id, post_id, read, published_at) +SELECT + recipient_id, + post_id, + read, + published_at +FROM + notification +WHERE + kind = 'Mention' + AND post_id IS NOT NULL; + +INSERT INTO inbox_combined (person_post_mention_id, published_at) +SELECT + id, + published_at +FROM + person_post_mention; + +-- copy back data to person_comment_mention table +INSERT INTO person_comment_mention (recipient_id, comment_id, read, published_at) +SELECT + recipient_id, + comment_id, + read, + published_at +FROM + notification +WHERE + kind = 'Mention' + AND comment_id IS NOT NULL; + +-- copy back data to person_comment_mention table +UPDATE + private_message p +SET + read = n.read +FROM + notification n +WHERE + p.id = n.private_message_id; + +INSERT INTO inbox_combined (person_comment_mention_id, published_at) +SELECT + id, + published_at +FROM + person_comment_mention; + +-- copy back data to comment_reply table +INSERT INTO comment_reply (recipient_id, comment_id, read, published_at) +SELECT + recipient_id, + comment_id, + read, + published_at +FROM + notification +WHERE + kind = 'Reply' + AND comment_id IS NOT NULL; + +INSERT INTO inbox_combined (comment_reply_id, published_at) +SELECT + id, + published_at +FROM + comment_reply; + +ALTER TABLE ONLY person_post_mention + ADD CONSTRAINT person_post_mention_unique UNIQUE (recipient_id, post_id); + +ALTER TABLE inbox_combined + ADD CONSTRAINT inbox_combined_check CHECK (num_nonnulls (comment_reply_id, person_comment_mention_id, person_post_mention_id, private_message_id) = 1); + +CREATE INDEX idx_comment_reply_comment ON comment_reply USING btree (comment_id); + +CREATE INDEX idx_comment_reply_recipient ON comment_reply USING btree (recipient_id); + +CREATE INDEX idx_comment_reply_published ON comment_reply USING btree (published_at DESC); + +CREATE INDEX idx_inbox_combined_published_asc ON inbox_combined USING btree (reverse_timestamp_sort (published_at) DESC, id DESC); + +CREATE INDEX idx_inbox_combined_published ON inbox_combined USING btree (published_at DESC, id DESC); + +DROP TABLE notification; + +DROP TYPE notification_type_enum; + +ALTER TABLE community_actions + DROP COLUMN notifications; + +DROP TYPE community_notifications_mode_enum; + +ALTER TABLE post_actions + DROP COLUMN notifications; + +DROP TYPE post_notifications_mode_enum; + diff --git a/migrations/2025-07-17-103657_post-or-comment-notification/up.sql b/migrations/2025-07-17-103657_post-or-comment-notification/up.sql new file mode 100644 index 000000000..a55c1f063 --- /dev/null +++ b/migrations/2025-07-17-103657_post-or-comment-notification/up.sql @@ -0,0 +1,104 @@ +-- create new data types +CREATE TYPE notification_type_enum AS enum ( + 'Mention', + 'Reply', + 'Subscribed', + 'PrivateMessage' +); + +-- create notification table by renaming comment_reply, to avoid copying lots of data around +ALTER TABLE comment_reply RENAME TO notification; + +ALTER INDEX idx_comment_reply_comment RENAME TO idx_notification_comment; + +ALTER INDEX idx_comment_reply_recipient RENAME TO idx_notification_recipient; + +ALTER INDEX idx_comment_reply_published RENAME TO idx_notification_published; + +ALTER SEQUENCE comment_reply_id_seq + RENAME TO notification_id_seq; + +ALTER TABLE notification RENAME CONSTRAINT comment_reply_comment_id_fkey TO notification_comment_id_fkey; + +ALTER TABLE notification RENAME CONSTRAINT comment_reply_pkey TO notification_pkey; + +ALTER TABLE notification + DROP CONSTRAINT comment_reply_recipient_id_comment_id_key; + +ALTER TABLE notification RENAME CONSTRAINT comment_reply_recipient_id_fkey TO notification_recipient_id_fkey; + +ALTER TABLE notification + ADD COLUMN kind notification_type_enum NOT NULL DEFAULT 'Reply', + ALTER COLUMN comment_id DROP NOT NULL, + ADD COLUMN post_id int REFERENCES post (id) ON UPDATE CASCADE ON DELETE CASCADE, + ADD COLUMN private_message_id int REFERENCES private_message (id) ON UPDATE CASCADE ON DELETE CASCADE; + +ALTER TABLE notification + ALTER COLUMN kind DROP DEFAULT; + +-- copy data from person_post_mention table +INSERT INTO notification (post_id, recipient_id, kind, read, published_at) +SELECT + post_id, + recipient_id, + 'Mention', + read, + published_at +FROM + person_post_mention; + +-- copy data from person_comment_mention table +INSERT INTO notification (comment_id, recipient_id, kind, read, published_at) +SELECT + comment_id, + recipient_id, + 'Mention', + read, + published_at +FROM + person_comment_mention; + +-- copy data from private_message table +INSERT INTO notification (private_message_id, recipient_id, kind, read, published_at) +SELECT + id, + recipient_id, + 'PrivateMessage', + read, + published_at +FROM + private_message; + +ALTER TABLE private_message + DROP COLUMN read; + +ALTER TABLE notification + ADD CONSTRAINT notification_check CHECK (num_nonnulls (post_id, comment_id, private_message_id) = 1); + +CREATE INDEX idx_notification_recipient_published ON notification (recipient_id, published_at); + +CREATE INDEX idx_notification_post ON notification (post_id); + +CREATE INDEX idx_notification_private_message ON notification (private_message_id); + +DROP TABLE inbox_combined, person_post_mention, person_comment_mention; + +CREATE TYPE post_notifications_mode_enum AS enum ( + 'AllComments', + 'RepliesAndMentions', + 'Mute' +); + +ALTER TABLE post_actions + ADD COLUMN notifications post_notifications_mode_enum; + +CREATE TYPE community_notifications_mode_enum AS enum ( + 'AllPostsAndComments', + 'AllPosts', + 'RepliesAndMentions', + 'Mute' +); + +ALTER TABLE community_actions + ADD COLUMN notifications community_notifications_mode_enum; + diff --git a/src/api_routes.rs b/src/api_routes.rs index a842b6e16..dedc1adec 100644 --- a/src/api_routes.rs +++ b/src/api_routes.rs @@ -20,6 +20,7 @@ use lemmy_api::{ random::get_random_community, tag::{create_community_tag, delete_community_tag, update_community_tag}, transfer::transfer_community, + update_notifications::update_community_notifications, }, local_user::{ add_admin::add_admin, @@ -41,11 +42,9 @@ use lemmy_api::{ logout::logout, note_person::user_note_person, notifications::{ - list_inbox::list_inbox, + list::list_notifications, mark_all_read::mark_all_notifications_read, - mark_comment_mention_read::mark_comment_mention_as_read, - mark_post_mention_read::mark_post_mention_as_read, - mark_reply_read::mark_reply_as_read, + mark_notification_read::mark_notification_as_read, unread_count::unread_count, }, report_count::report_count, @@ -67,8 +66,8 @@ use lemmy_api::{ mark_many_read::mark_posts_as_read, mark_read::mark_post_as_read, save::save_post, + update_notifications::update_post_notifications, }, - private_message::mark_read::mark_pm_as_read, reports::{ comment_report::{create::create_comment_report, resolve::resolve_comment_report}, community_report::{create::create_community_report, resolve::resolve_community_report}, @@ -247,6 +246,7 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimit) { .route("/tag", post().to(create_community_tag)) .route("/tag", put().to(update_community_tag)) .route("/tag", delete().to(delete_community_tag)) + .route("/notifications", post().to(update_community_notifications)) .service( scope("/pending_follows") .route("/count", get().to(get_pending_follows_count)) @@ -294,7 +294,8 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimit) { .route("/like/list", get().to(list_post_likes)) .route("/save", put().to(save_post)) .route("/report", post().to(create_post_report)) - .route("/report/resolve", put().to(resolve_post_report)), + .route("/report/resolve", put().to(resolve_post_report)) + .route("/notifications", post().to(update_post_notifications)), ) // Comment .service( @@ -310,7 +311,6 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimit) { .route("", put().to(update_comment)) .route("/delete", post().to(delete_comment)) .route("/remove", post().to(remove_comment)) - .route("/mark_as_read", post().to(mark_reply_as_read)) .route("/distinguish", post().to(distinguish_comment)) .route("/like", post().to(like_comment)) .route("/like/list", get().to(list_comment_likes)) @@ -326,7 +326,6 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimit) { .route("", post().to(create_private_message)) .route("", put().to(update_private_message)) .route("/delete", post().to(delete_private_message)) - .route("/mark_as_read", post().to(mark_pm_as_read)) .route("/report", post().to(create_pm_report)) .route("/report/resolve", put().to(resolve_pm_report)), ) @@ -364,17 +363,10 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimit) { .route("", delete().to(delete_image)) .route("/list", get().to(list_media)), ) - .route("/inbox", get().to(list_inbox)) + .route("/notifications", get().to(list_notifications)) .route("/delete", post().to(delete_account)) - .service( - scope("/mention") - .route( - "/comment/mark_as_read", - post().to(mark_comment_mention_as_read), - ) - .route("/post/mark_as_read", post().to(mark_post_mention_as_read)), - ) .route("/mark_as_read/all", post().to(mark_all_notifications_read)) + .route("/mark_as_read", post().to(mark_notification_as_read)) .route("/report_count", get().to(report_count)) .route("/unread_count", get().to(unread_count)) .route("/list_logins", get().to(list_logins))