From 3f06317878d986d738a767770f848eebf5cdc684 Mon Sep 17 00:00:00 2001 From: Dessalines Date: Wed, 15 Jan 2025 12:51:39 -0500 Subject: [PATCH] Adding combined inbox (#5257) * Renaming person_mention to person_comment_mention. * Finishing up post body mentions. * Combined tables try 2 * Finishing up combined report table. * Fix ts optionals. * Adding tests, triggers, and history updates for report_combined. * Adding profile. * Add cursor pagination to report_combined view (#5244) * add pagination cursor * store timestamp instead of id in cursor (partial) * Revert "store timestamp instead of id in cursor (partial)" This reverts commit 89359dde4bc5fee39fdd2840828330f398444a36. * use paginated query builder * Fixing migration and paged API. * Using dullbananas trigger procedure * Removing pointless list routes, reorganizing tests. * Fixing column XOR check. * Forgot to remove list report actions. * Cleanup. * Use internal tagging. * Fixing api tests. * Adding a few indexes. * Fixing migration name. * Fixing unique constraints. * Addressing PR comments. * Start working on profile combined * Adding views and replaceable schema. * A few changes to profile view. - Separating the profile fetch from its combined content fetch. - Starting to separate saved_only into its own combined view. * Finishing up combined person_saved and person_content. * Fixing api tests. * Moving to api-v4 routes. * Fixing imports. * Update crates/db_views/src/report_combined_view.rs Co-authored-by: dullbananas * Update crates/db_views/src/report_combined_view.rs Co-authored-by: dullbananas * Update crates/db_views/src/report_combined_view.rs Co-authored-by: dullbananas * Update migrations/2024-12-02-181601_add_report_combined_table/up.sql Co-authored-by: dullbananas * Update migrations/2024-12-02-181601_add_report_combined_table/up.sql Co-authored-by: dullbananas * Fixing import and fmt. * Fixing null types in postgres. * Comment out err. * Fixing TS issues. * Adding types, fixing allow and blocklist crud. * Starting to work on combined views. * Using dullbananas trigger procedure * Adding the full combined view queries. * Adding tests. * taplo fmt. * Upgrading package.json deps. * Updating pnpm * Most of the bulk work done, need to add tests yet. * Finishing up inbox. * Using assert_length * Fixing sql_format. * Running fmt. * Fixing cargo shear. * Fixing clippy. * Addressing PR comments. * Removing serialization * Removing serialization * Fixing duped trigger. * Remove saved_only test. * Remove pointless post_tags types. * Remove pointless index. * Changing published to saved for person_saved_combined. * Removing comment. * Renaming modlog when_ columns to published. - Fixes #5312 * Adding strum and simplifying imports. * Avoiding clone in map_to_enum * Changing modded_person to other_person. * Update crates/db_views_moderator/src/modlog_combined_view.rs Co-authored-by: dullbananas * Update crates/db_views_moderator/src/modlog_combined_view.rs Co-authored-by: dullbananas * Update crates/db_views_moderator/src/modlog_combined_view.rs Co-authored-by: dullbananas * Addressing PR comments. * Fixing split. * Revert "Adding strum and simplifying imports." This reverts commit 15f167110721429dd6e465f522250c8beb3d4dd7. * Running fmt. * Using assert + matches instead of filter_map. * Adding listPersonContent check. * Updating lemmy-js-client * Fixing mark all as read route, changing mark read to SuccessResponse. * Adding post body mention api test, fixing api tests. * Fixing route locations, and api tests. --------- Co-authored-by: dullbananas --- Cargo.lock | 2 +- api_tests/package.json | 2 +- api_tests/pnpm-lock.yaml | 10 +- api_tests/src/comment.spec.ts | 75 +- api_tests/src/post.spec.ts | 40 + api_tests/src/private_message.spec.ts | 56 +- api_tests/src/shared.ts | 39 +- crates/api/src/comment/like.rs | 6 +- .../local_user/notifications/list_inbox.rs | 40 + .../local_user/notifications/list_mentions.rs | 36 - .../local_user/notifications/list_replies.rs | 36 - .../local_user/notifications/mark_all_read.rs | 18 +- .../mark_comment_mention_read.rs | 39 + .../notifications/mark_mention_read.rs | 45 - .../notifications/mark_post_mention_read.rs | 39 + .../notifications/mark_reply_read.rs | 15 +- .../api/src/local_user/notifications/mod.rs | 6 +- .../local_user/notifications/unread_count.rs | 22 +- crates/api/src/post/like.rs | 5 +- crates/api/src/private_message/mark_read.rs | 12 +- crates/api_common/src/build_response.rs | 264 +++-- crates/api_common/src/person.rs | 84 +- crates/api_common/src/private_message.rs | 27 +- crates/api_common/src/send_activity.rs | 2 +- crates/api_common/src/utils.rs | 24 +- crates/api_crud/src/comment/create.rs | 20 +- crates/api_crud/src/comment/delete.rs | 3 +- crates/api_crud/src/comment/remove.rs | 3 +- crates/api_crud/src/comment/update.rs | 3 +- crates/api_crud/src/post/create.rs | 16 +- crates/api_crud/src/post/update.rs | 16 +- crates/api_crud/src/private_message/create.rs | 3 +- crates/api_crud/src/private_message/delete.rs | 3 +- crates/api_crud/src/private_message/mod.rs | 1 - crates/api_crud/src/private_message/read.rs | 33 - crates/api_crud/src/private_message/update.rs | 3 +- .../activities/create_or_update/comment.rs | 13 +- .../src/activities/create_or_update/post.rs | 24 +- .../create_or_update/private_message.rs | 2 +- .../db_schema/replaceable_schema/triggers.sql | 32 + crates/db_schema/src/impls/mod.rs | 3 +- .../src/impls/person_comment_mention.rs | 83 ++ crates/db_schema/src/impls/person_mention.rs | 76 -- .../src/impls/person_post_mention.rs | 83 ++ crates/db_schema/src/lib.rs | 12 + crates/db_schema/src/newtypes.rs | 24 +- crates/db_schema/src/schema.rs | 39 +- crates/db_schema/src/source/combined/inbox.rs | 33 + crates/db_schema/src/source/combined/mod.rs | 1 + crates/db_schema/src/source/mod.rs | 3 +- ...n_mention.rs => person_comment_mention.rs} | 18 +- .../src/source/person_post_mention.rs | 39 + crates/db_views/src/lib.rs | 2 - crates/db_views/src/structs.rs | 11 - crates/db_views_actor/Cargo.toml | 3 +- .../db_views_actor/src/comment_reply_view.rs | 379 ------- .../db_views_actor/src/inbox_combined_view.rs | 991 ++++++++++++++++++ crates/db_views_actor/src/lib.rs | 6 +- .../db_views_actor/src/person_mention_view.rs | 383 ------- .../src/private_message_view.rs | 42 + crates/db_views_actor/src/structs.rs | 108 +- crates/routes/src/feeds.rs | 106 +- crates/utils/src/error.rs | 2 +- .../up.sql | 8 +- .../down.sql | 6 + .../up.sql | 69 ++ src/api_routes_v3.rs | 10 +- src/api_routes_v4.rs | 41 +- 68 files changed, 2193 insertions(+), 1507 deletions(-) create mode 100644 crates/api/src/local_user/notifications/list_inbox.rs delete mode 100644 crates/api/src/local_user/notifications/list_mentions.rs delete mode 100644 crates/api/src/local_user/notifications/list_replies.rs create mode 100644 crates/api/src/local_user/notifications/mark_comment_mention_read.rs delete mode 100644 crates/api/src/local_user/notifications/mark_mention_read.rs create mode 100644 crates/api/src/local_user/notifications/mark_post_mention_read.rs delete mode 100644 crates/api_crud/src/private_message/read.rs create mode 100644 crates/db_schema/src/impls/person_comment_mention.rs delete mode 100644 crates/db_schema/src/impls/person_mention.rs create mode 100644 crates/db_schema/src/impls/person_post_mention.rs create mode 100644 crates/db_schema/src/source/combined/inbox.rs rename crates/db_schema/src/source/{person_mention.rs => person_comment_mention.rs} (63%) create mode 100644 crates/db_schema/src/source/person_post_mention.rs delete mode 100644 crates/db_views_actor/src/comment_reply_view.rs create mode 100644 crates/db_views_actor/src/inbox_combined_view.rs delete mode 100644 crates/db_views_actor/src/person_mention_view.rs create mode 100644 crates/db_views_actor/src/private_message_view.rs create mode 100644 migrations/2024-12-10-193418_add_inbox_combined_table/down.sql create mode 100644 migrations/2024-12-10-193418_add_inbox_combined_table/up.sql diff --git a/Cargo.lock b/Cargo.lock index b807198b9..b3106adc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2752,8 +2752,8 @@ dependencies = [ "chrono", "diesel", "diesel-async", + "i-love-jesus", "lemmy_db_schema", - "lemmy_db_views", "lemmy_utils", "pretty_assertions", "serde", diff --git a/api_tests/package.json b/api_tests/package.json index 7e1fd8127..f9c67eea7 100644 --- a/api_tests/package.json +++ b/api_tests/package.json @@ -28,7 +28,7 @@ "eslint": "^9.18.0", "eslint-plugin-prettier": "^5.1.3", "jest": "^29.5.0", - "lemmy-js-client": "0.20.0-no-delete-token.2", + "lemmy-js-client": "0.20.0-inbox-combined.1", "prettier": "^3.4.2", "ts-jest": "^29.1.0", "typescript": "^5.7.3", diff --git a/api_tests/pnpm-lock.yaml b/api_tests/pnpm-lock.yaml index 1a68b2777..0a662e576 100644 --- a/api_tests/pnpm-lock.yaml +++ b/api_tests/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^29.5.0 version: 29.7.0(@types/node@22.10.6) lemmy-js-client: - specifier: 0.20.0-no-delete-token.2 - version: 0.20.0-no-delete-token.2 + specifier: 0.20.0-inbox-combined.1 + version: 0.20.0-inbox-combined.1 prettier: specifier: ^3.4.2 version: 3.4.2 @@ -1157,8 +1157,8 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} - lemmy-js-client@0.20.0-no-delete-token.2: - resolution: {integrity: sha512-3ra3DpD8XR6RRwCeUDLI/ztFgVuF1IoUoft+xKVDALyupwRWUsA3JcHXRIcFd1a2Qt+pHJtWbc5Iwvybakxwdg==} + lemmy-js-client@0.20.0-inbox-combined.1: + resolution: {integrity: sha512-sFJJePXdMHIVQwCa3fN+nIcIvfD7ZbBEZn08fmITXEA6/qbJLvZGWG/rEcRNkZM+lRKnhfrZihWKx1AHZE9wqA==} leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} @@ -3060,7 +3060,7 @@ snapshots: kleur@3.0.3: {} - lemmy-js-client@0.20.0-no-delete-token.2: {} + lemmy-js-client@0.20.0-inbox-combined.1: {} leven@3.1.0: {} diff --git a/api_tests/src/comment.spec.ts b/api_tests/src/comment.spec.ts index 0a937847e..42fc06882 100644 --- a/api_tests/src/comment.spec.ts +++ b/api_tests/src/comment.spec.ts @@ -16,7 +16,6 @@ import { editComment, deleteComment, removeComment, - getMentions, resolvePost, unfollowRemotes, createCommunity, @@ -27,7 +26,6 @@ import { getComments, getCommentParentId, resolveCommunity, - getReplies, getUnreadCount, waitUntil, waitForPost, @@ -38,11 +36,14 @@ import { saveUserSettings, listReports, listPersonContent, + listInbox, } from "./shared"; import { + CommentReplyView, CommentReportView, CommentView, CommunityView, + PersonCommentMentionView, ReportCombinedView, SaveUserSettings, } from "lemmy-js-client"; @@ -356,7 +357,7 @@ test("Federated comment like", async () => { }); test("Reply to a comment from another instance, get notification", async () => { - await alpha.markAllAsRead(); + await alpha.markAllNotificationsAsRead(); let betaCommunity = ( await waitUntil( @@ -423,18 +424,18 @@ test("Reply to a comment from another instance, get notification", async () => { // Did alpha get notified of the reply from beta? let alphaUnreadCountRes = await waitUntil( () => getUnreadCount(alpha), - e => e.replies >= 1, + e => e.count >= 1, ); - expect(alphaUnreadCountRes.replies).toBeGreaterThanOrEqual(1); + expect(alphaUnreadCountRes.count).toBeGreaterThanOrEqual(1); // check inbox of replies on alpha, fetching read/unread both let alphaRepliesRes = await waitUntil( - () => getReplies(alpha), - r => r.replies.length > 0, - ); - const alphaReply = alphaRepliesRes.replies.find( - r => r.comment.id === alphaComment.comment.id, + () => listInbox(alpha, "CommentReply"), + r => r.inbox.length > 0, ); + 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(); @@ -463,7 +464,7 @@ test("Bot reply notifications are filtered when bots are hidden", async () => { throw "Missing alpha community"; } - await alpha.markAllAsRead(); + await alpha.markAllNotificationsAsRead(); form = { show_bot_accounts: false, }; @@ -478,10 +479,7 @@ test("Bot reply notifications are filtered when bots are hidden", async () => { expect(commentRes).toBeDefined(); let alphaUnreadCountRes = await getUnreadCount(alpha); - expect(alphaUnreadCountRes.replies).toBe(0); - - let alphaUnreadRepliesRes = await getReplies(alpha, true); - expect(alphaUnreadRepliesRes.replies.length).toBe(0); + expect(alphaUnreadCountRes.count).toBe(0); // This both restores the original state that may be expected by other tests // implicitly and is used by the next steps to ensure replies are still @@ -492,16 +490,16 @@ test("Bot reply notifications are filtered when bots are hidden", async () => { await saveUserSettings(alpha, form); alphaUnreadCountRes = await getUnreadCount(alpha); - expect(alphaUnreadCountRes.replies).toBe(1); + expect(alphaUnreadCountRes.count).toBe(1); - alphaUnreadRepliesRes = await getReplies(alpha, true); - expect(alphaUnreadRepliesRes.replies.length).toBe(1); - expect(alphaUnreadRepliesRes.replies[0].comment.id).toBe( + let alphaUnreadRepliesRes = await listInbox(alpha, "CommentReply", true); + expect(alphaUnreadRepliesRes.inbox.length).toBe(1); + expect((alphaUnreadRepliesRes.inbox[0] as CommentReplyView).comment.id).toBe( commentRes.comment_view.comment.id, ); }); -test("Mention beta from alpha", async () => { +test("Mention beta from alpha comment", async () => { if (!betaCommunity) throw Error("no community"); const postOnAlphaRes = await createPost(alpha, betaCommunity.community.id); // Create a new branch, trunk-level comment branch, from alpha instance @@ -548,15 +546,17 @@ test("Mention beta from alpha", async () => { assertCommentFederation(betaRootComment, commentRes.comment_view); let mentionsRes = await waitUntil( - () => getMentions(beta), - m => !!m.mentions[0], + () => listInbox(beta, "CommentMention"), + m => !!m.inbox[0], ); - expect(mentionsRes.mentions[0].comment.content).toBeDefined(); - expect(mentionsRes.mentions[0].community.local).toBe(true); - expect(mentionsRes.mentions[0].creator.local).toBe(false); - expect(mentionsRes.mentions[0].counts.score).toBe(1); + + 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.counts.score).toBe(1); // the reply comment with mention should be the most fresh, newest, index 0 - expect(mentionsRes.mentions[0].person_mention.comment_id).toBe( + expect(firstMention.person_comment_mention.comment_id).toBe( betaPostComments.comments[0].comment.id, ); }); @@ -623,15 +623,17 @@ 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( () => - getMentions(beta).then(m => - m.mentions.find( - m => m.comment.ap_id === commentRes.comment_view.comment.ap_id, + listInbox(beta, "CommentMention").then(m => + m.inbox.find( + m => + m.type_ == "CommentMention" && + m.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); @@ -824,6 +826,7 @@ test("Report a comment", async () => { }); test("Dont send a comment reply to a blocked community", async () => { + await beta.markAllNotificationsAsRead(); let newCommunity = await createCommunity(beta); let newCommunityId = newCommunity.community_view.community.id; @@ -837,7 +840,7 @@ test("Dont send a comment reply to a blocked community", async () => { // Check beta's inbox count let unreadCount = await getUnreadCount(beta); - expect(unreadCount.replies).toBe(1); + expect(unreadCount.count).toBe(0); // Beta blocks the new beta community let blockRes = await blockCommunity(beta, newCommunityId, true); @@ -857,10 +860,10 @@ test("Dont send a comment reply to a blocked community", async () => { // Check beta's inbox count, make sure it stays the same unreadCount = await getUnreadCount(beta); - expect(unreadCount.replies).toBe(1); + expect(unreadCount.count).toBe(0); - let replies = await getReplies(beta); - expect(replies.replies.length).toBe(1); + let replies = await listInbox(beta, "CommentReply", true); + expect(replies.inbox.length).toBe(0); // Unblock the community blockRes = await blockCommunity(beta, newCommunityId, false); diff --git a/api_tests/src/post.spec.ts b/api_tests/src/post.spec.ts index 52f86e8ef..8ac46de22 100644 --- a/api_tests/src/post.spec.ts +++ b/api_tests/src/post.spec.ts @@ -38,11 +38,13 @@ import { createCommunity, listReports, getMyUser, + listInbox, } from "./shared"; import { PostView } from "lemmy-js-client/dist/types/PostView"; import { AdminBlockInstanceParams } from "lemmy-js-client/dist/types/AdminBlockInstanceParams"; import { EditSite, + PersonPostMentionView, PostReport, PostReportView, ReportCombinedView, @@ -799,6 +801,44 @@ test("Fetch post with redirect", async () => { expect(gammaPost2.post).toBeDefined(); }); +test("Mention beta from alpha post body", async () => { + if (!betaCommunity) throw Error("no community"); + let mentionContent = "A test mention of @lemmy_beta@lemmy-beta:8551"; + + const postOnAlphaRes = await createPost( + alpha, + betaCommunity.community.id, + undefined, + mentionContent, + ); + + expect(postOnAlphaRes.post_view.post.body).toBeDefined(); + expect(postOnAlphaRes.post_view.community.local).toBe(false); + expect(postOnAlphaRes.post_view.creator.local).toBe(true); + expect(postOnAlphaRes.post_view.counts.score).toBe(1); + + // get beta's localized copy of the alpha post + let betaPost = await waitForPost(beta, postOnAlphaRes.post_view.post); + if (!betaPost) { + throw "unable to locate post on beta"; + } + expect(betaPost.post.ap_id).toBe(postOnAlphaRes.post_view.post.ap_id); + expect(betaPost.post.name).toBe(postOnAlphaRes.post_view.post.name); + await assertPostFederation(betaPost, postOnAlphaRes.post_view); + + let mentionsRes = await waitUntil( + () => listInbox(beta, "PostMention"), + m => !!m.inbox[0], + ); + + const firstMention = mentionsRes.inbox[0] as PersonPostMentionView; + expect(firstMention.post.body).toBeDefined(); + expect(firstMention.community.local).toBe(true); + expect(firstMention.creator.local).toBe(false); + expect(firstMention.counts.score).toBe(1); + expect(firstMention.person_post_mention.post_id).toBe(betaPost.post.id); +}); + test("Rewrite markdown links", async () => { const community = (await resolveBetaCommunity(beta)).community!; diff --git a/api_tests/src/private_message.spec.ts b/api_tests/src/private_message.spec.ts index 8fd683ff0..8f3b0a36e 100644 --- a/api_tests/src/private_message.spec.ts +++ b/api_tests/src/private_message.spec.ts @@ -1,4 +1,5 @@ jest.setTimeout(120000); +import { PrivateMessageView } from "lemmy-js-client"; import { alpha, beta, @@ -6,11 +7,11 @@ import { followBeta, createPrivateMessage, editPrivateMessage, - listPrivateMessages, deletePrivateMessage, waitUntil, reportPrivateMessage, unfollows, + listInbox, } from "./shared"; let recipient_id: number; @@ -31,13 +32,14 @@ test("Create a private message", async () => { expect(pmRes.private_message_view.recipient.local).toBe(false); let betaPms = await waitUntil( - () => listPrivateMessages(beta), - e => !!e.private_messages[0], + () => listInbox(beta, "PrivateMessage"), + e => !!e.inbox[0], ); - expect(betaPms.private_messages[0].private_message.content).toBeDefined(); - expect(betaPms.private_messages[0].private_message.local).toBe(false); - expect(betaPms.private_messages[0].creator.local).toBe(false); - expect(betaPms.private_messages[0].recipient.local).toBe(true); + const firstPm = betaPms.inbox[0] as PrivateMessageView; + expect(firstPm.private_message.content).toBeDefined(); + expect(firstPm.private_message.local).toBe(false); + expect(firstPm.creator.local).toBe(false); + expect(firstPm.recipient.local).toBe(true); }); test("Update a private message", async () => { @@ -53,10 +55,12 @@ test("Update a private message", async () => { ); let betaPms = await waitUntil( - () => listPrivateMessages(beta), - p => p.private_messages[0].private_message.content === updatedContent, + () => listInbox(beta, "PrivateMessage"), + p => + p.inbox[0].type_ == "PrivateMessage" && + p.inbox[0].private_message.content === updatedContent, ); - expect(betaPms.private_messages[0].private_message.content).toBe( + expect((betaPms.inbox[0] as PrivateMessageView).private_message.content).toBe( updatedContent, ); }); @@ -64,12 +68,13 @@ test("Update a private message", async () => { test("Delete a private message", async () => { let pmRes = await createPrivateMessage(alpha, recipient_id); let betaPms1 = await waitUntil( - () => listPrivateMessages(beta), + () => listInbox(beta, "PrivateMessage"), m => - !!m.private_messages.find( + !!m.inbox.find( e => + e.type_ == "PrivateMessage" && e.private_message.ap_id === - pmRes.private_message_view.private_message.ap_id, + pmRes.private_message_view.private_message.ap_id, ), ); let deletedPmRes = await deletePrivateMessage( @@ -83,12 +88,10 @@ test("Delete a private message", async () => { // even though they are in the actual database. // no reason to show them let betaPms2 = await waitUntil( - () => listPrivateMessages(beta), - p => p.private_messages.length === betaPms1.private_messages.length - 1, - ); - expect(betaPms2.private_messages.length).toBe( - betaPms1.private_messages.length - 1, + () => listInbox(beta, "PrivateMessage"), + p => p.inbox.length === betaPms1.inbox.length - 1, ); + expect(betaPms2.inbox.length).toBe(betaPms1.inbox.length - 1); // Undelete let undeletedPmRes = await deletePrivateMessage( @@ -101,26 +104,25 @@ test("Delete a private message", async () => { ); let betaPms3 = await waitUntil( - () => listPrivateMessages(beta), - p => p.private_messages.length === betaPms1.private_messages.length, - ); - expect(betaPms3.private_messages.length).toBe( - betaPms1.private_messages.length, + () => listInbox(beta, "PrivateMessage"), + p => p.inbox.length === betaPms1.inbox.length, ); + expect(betaPms3.inbox.length).toBe(betaPms1.inbox.length); }); test("Create a private message report", async () => { let pmRes = await createPrivateMessage(alpha, recipient_id); let betaPms1 = await waitUntil( - () => listPrivateMessages(beta), + () => listInbox(beta, "PrivateMessage"), m => - !!m.private_messages.find( + !!m.inbox.find( e => + e.type_ == "PrivateMessage" && e.private_message.ap_id === - pmRes.private_message_view.private_message.ap_id, + pmRes.private_message_view.private_message.ap_id, ), ); - let betaPm = betaPms1.private_messages[0]; + let betaPm = betaPms1.inbox[0] 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 6c4b4eba0..5e9513df8 100644 --- a/api_tests/src/shared.ts +++ b/api_tests/src/shared.ts @@ -7,8 +7,6 @@ import { CreatePrivateMessageReport, EditCommunity, GetCommunityPendingFollowsCountResponse, - GetReplies, - GetRepliesResponse, GetUnreadCountResponse, InstanceId, LemmyHttp, @@ -26,6 +24,9 @@ import { ListPersonContentResponse, ListPersonContent, PersonContentType, + ListInboxResponse, + ListInbox, + InboxDataType, } from "lemmy-js-client"; import { CreatePost } from "lemmy-js-client/dist/types/CreatePost"; import { DeletePost } from "lemmy-js-client/dist/types/DeletePost"; @@ -59,8 +60,6 @@ import { CreateComment } from "lemmy-js-client/dist/types/CreateComment"; import { EditComment } from "lemmy-js-client/dist/types/EditComment"; import { DeleteComment } from "lemmy-js-client/dist/types/DeleteComment"; import { RemoveComment } from "lemmy-js-client/dist/types/RemoveComment"; -import { GetPersonMentionsResponse } from "lemmy-js-client/dist/types/GetPersonMentionsResponse"; -import { GetPersonMentions } from "lemmy-js-client/dist/types/GetPersonMentions"; import { CreateCommentLike } from "lemmy-js-client/dist/types/CreateCommentLike"; import { CreateCommunity } from "lemmy-js-client/dist/types/CreateCommunity"; import { GetCommunity } from "lemmy-js-client/dist/types/GetCommunity"; @@ -75,8 +74,6 @@ import { Register } from "lemmy-js-client/dist/types/Register"; import { SaveUserSettings } from "lemmy-js-client/dist/types/SaveUserSettings"; import { DeleteAccount } from "lemmy-js-client/dist/types/DeleteAccount"; import { GetSiteResponse } from "lemmy-js-client/dist/types/GetSiteResponse"; -import { PrivateMessagesResponse } from "lemmy-js-client/dist/types/PrivateMessagesResponse"; -import { GetPrivateMessages } from "lemmy-js-client/dist/types/GetPrivateMessages"; import { PostReportResponse } from "lemmy-js-client/dist/types/PostReportResponse"; import { CreatePostReport } from "lemmy-js-client/dist/types/CreatePostReport"; import { CommentReportResponse } from "lemmy-js-client/dist/types/CommentReportResponse"; @@ -377,15 +374,16 @@ export async function getUnreadCount( return api.getUnreadCount(); } -export async function getReplies( +export async function listInbox( api: LemmyHttp, + type_?: InboxDataType, unread_only: boolean = false, -): Promise { - let form: GetReplies = { - sort: "New", +): Promise { + let form: ListInbox = { unread_only, + type_, }; - return api.getReplies(form); + return api.listInbox(form); } export async function resolveComment( @@ -542,16 +540,6 @@ export async function removeComment( return api.removeComment(form); } -export async function getMentions( - api: LemmyHttp, -): Promise { - let form: GetPersonMentions = { - sort: "New", - unread_only: false, - }; - return api.getPersonMentions(form); -} - export async function likeComment( api: LemmyHttp, score: number, @@ -777,15 +765,6 @@ export async function getMyUser(api: LemmyHttp): Promise { return api.getMyUser(); } -export async function listPrivateMessages( - api: LemmyHttp, -): Promise { - let form: GetPrivateMessages = { - unread_only: false, - }; - return api.getPrivateMessages(form); -} - export async function unfollowRemotes(api: LemmyHttp): Promise { // Unfollow all remote communities let my_user = await getMyUser(api); diff --git a/crates/api/src/comment/like.rs b/crates/api/src/comment/like.rs index fbc720102..0815b3863 100644 --- a/crates/api/src/comment/like.rs +++ b/crates/api/src/comment/like.rs @@ -5,10 +5,10 @@ use lemmy_api_common::{ comment::{CommentResponse, CreateCommentLike}, context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, - utils::{check_bot_account, check_community_user_action, check_local_vote_mode, VoteItem}, + utils::{check_bot_account, check_community_user_action, check_local_vote_mode}, }; use lemmy_db_schema::{ - newtypes::LocalUserId, + newtypes::{LocalUserId, PostOrCommentId}, source::{ comment::{CommentLike, CommentLikeForm}, comment_reply::CommentReply, @@ -33,7 +33,7 @@ pub async fn like_comment( check_local_vote_mode( data.score, - VoteItem::Comment(comment_id), + PostOrCommentId::Comment(comment_id), &local_site, local_user_view.person.id, &mut context.pool(), diff --git a/crates/api/src/local_user/notifications/list_inbox.rs b/crates/api/src/local_user/notifications/list_inbox.rs new file mode 100644 index 000000000..7d6e88468 --- /dev/null +++ b/crates/api/src/local_user/notifications/list_inbox.rs @@ -0,0 +1,40 @@ +use actix_web::web::{Data, Json, Query}; +use lemmy_api_common::{ + context::LemmyContext, + person::{ListInbox, ListInboxResponse}, +}; +use lemmy_db_views::structs::LocalUserView; +use lemmy_db_views_actor::inbox_combined_view::InboxCombinedQuery; +use lemmy_utils::error::LemmyResult; + +#[tracing::instrument(skip(context))] +pub async fn list_inbox( + data: Query, + context: Data, + local_user_view: LocalUserView, +) -> LemmyResult> { + let unread_only = data.unread_only; + let type_ = data.type_; + let person_id = local_user_view.person.id; + let show_bot_accounts = Some(local_user_view.local_user.show_bot_accounts); + + // parse pagination token + let page_after = if let Some(pa) = &data.page_cursor { + Some(pa.read(&mut context.pool()).await?) + } else { + None + }; + let page_back = data.page_back; + + let inbox = InboxCombinedQuery { + type_, + unread_only, + show_bot_accounts, + page_after, + page_back, + } + .list(&mut context.pool(), person_id) + .await?; + + Ok(Json(ListInboxResponse { inbox })) +} diff --git a/crates/api/src/local_user/notifications/list_mentions.rs b/crates/api/src/local_user/notifications/list_mentions.rs deleted file mode 100644 index bf3cd8e0d..000000000 --- a/crates/api/src/local_user/notifications/list_mentions.rs +++ /dev/null @@ -1,36 +0,0 @@ -use actix_web::web::{Data, Json, Query}; -use lemmy_api_common::{ - context::LemmyContext, - person::{GetPersonMentions, GetPersonMentionsResponse}, -}; -use lemmy_db_views::structs::LocalUserView; -use lemmy_db_views_actor::person_mention_view::PersonMentionQuery; -use lemmy_utils::error::LemmyResult; - -#[tracing::instrument(skip(context))] -pub async fn list_mentions( - data: Query, - context: Data, - local_user_view: LocalUserView, -) -> LemmyResult> { - let sort = data.sort; - let page = data.page; - let limit = data.limit; - let unread_only = data.unread_only.unwrap_or_default(); - let person_id = Some(local_user_view.person.id); - let show_bot_accounts = local_user_view.local_user.show_bot_accounts; - - let mentions = PersonMentionQuery { - recipient_id: person_id, - my_person_id: person_id, - sort, - unread_only, - show_bot_accounts, - page, - limit, - } - .list(&mut context.pool()) - .await?; - - Ok(Json(GetPersonMentionsResponse { mentions })) -} diff --git a/crates/api/src/local_user/notifications/list_replies.rs b/crates/api/src/local_user/notifications/list_replies.rs deleted file mode 100644 index d88595d96..000000000 --- a/crates/api/src/local_user/notifications/list_replies.rs +++ /dev/null @@ -1,36 +0,0 @@ -use actix_web::web::{Data, Json, Query}; -use lemmy_api_common::{ - context::LemmyContext, - person::{GetReplies, GetRepliesResponse}, -}; -use lemmy_db_views::structs::LocalUserView; -use lemmy_db_views_actor::comment_reply_view::CommentReplyQuery; -use lemmy_utils::error::LemmyResult; - -#[tracing::instrument(skip(context))] -pub async fn list_replies( - data: Query, - context: Data, - local_user_view: LocalUserView, -) -> LemmyResult> { - let sort = data.sort; - let page = data.page; - let limit = data.limit; - let unread_only = data.unread_only.unwrap_or_default(); - let person_id = Some(local_user_view.person.id); - let show_bot_accounts = local_user_view.local_user.show_bot_accounts; - - let replies = CommentReplyQuery { - recipient_id: person_id, - my_person_id: person_id, - sort, - unread_only, - show_bot_accounts, - page, - limit, - } - .list(&mut context.pool()) - .await?; - - Ok(Json(GetRepliesResponse { replies })) -} diff --git a/crates/api/src/local_user/notifications/mark_all_read.rs b/crates/api/src/local_user/notifications/mark_all_read.rs index 558d276f7..9ba0916f8 100644 --- a/crates/api/src/local_user/notifications/mark_all_read.rs +++ b/crates/api/src/local_user/notifications/mark_all_read.rs @@ -1,8 +1,9 @@ use actix_web::web::{Data, Json}; -use lemmy_api_common::{context::LemmyContext, person::GetRepliesResponse}; +use lemmy_api_common::{context::LemmyContext, SuccessResponse}; use lemmy_db_schema::source::{ comment_reply::CommentReply, - person_mention::PersonMention, + person_comment_mention::PersonCommentMention, + person_post_mention::PersonPostMention, private_message::PrivateMessage, }; use lemmy_db_views::structs::LocalUserView; @@ -12,7 +13,7 @@ use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; pub async fn mark_all_notifications_read( context: Data, local_user_view: LocalUserView, -) -> LemmyResult> { +) -> LemmyResult> { let person_id = local_user_view.person.id; // Mark all comment_replies as read @@ -20,15 +21,20 @@ pub async fn mark_all_notifications_read( .await .with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?; - // Mark all user mentions as read - PersonMention::mark_all_as_read(&mut context.pool(), person_id) + // Mark all comment mentions as read + PersonCommentMention::mark_all_as_read(&mut context.pool(), person_id) .await .with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?; + // Mark all post mentions as read + PersonPostMention::mark_all_as_read(&mut context.pool(), person_id) + .await + .with_lemmy_type(LemmyErrorType::CouldntUpdatePost)?; + // Mark all private_messages as read PrivateMessage::mark_all_as_read(&mut context.pool(), person_id) .await .with_lemmy_type(LemmyErrorType::CouldntUpdatePrivateMessage)?; - Ok(Json(GetRepliesResponse { replies: vec![] })) + Ok(Json(SuccessResponse::default())) } diff --git a/crates/api/src/local_user/notifications/mark_comment_mention_read.rs b/crates/api/src/local_user/notifications/mark_comment_mention_read.rs new file mode 100644 index 000000000..e7091549e --- /dev/null +++ b/crates/api/src/local_user/notifications/mark_comment_mention_read.rs @@ -0,0 +1,39 @@ +use actix_web::web::{Data, Json}; +use lemmy_api_common::{ + context::LemmyContext, + person::MarkPersonCommentMentionAsRead, + SuccessResponse, +}; +use lemmy_db_schema::{ + source::person_comment_mention::{PersonCommentMention, PersonCommentMentionUpdateForm}, + traits::Crud, +}; +use lemmy_db_views::structs::LocalUserView; +use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; + +#[tracing::instrument(skip(context))] +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 + .with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?; + + Ok(Json(SuccessResponse::default())) +} diff --git a/crates/api/src/local_user/notifications/mark_mention_read.rs b/crates/api/src/local_user/notifications/mark_mention_read.rs deleted file mode 100644 index 9a839b2b4..000000000 --- a/crates/api/src/local_user/notifications/mark_mention_read.rs +++ /dev/null @@ -1,45 +0,0 @@ -use actix_web::web::{Data, Json}; -use lemmy_api_common::{ - context::LemmyContext, - person::{MarkPersonMentionAsRead, PersonMentionResponse}, -}; -use lemmy_db_schema::{ - source::person_mention::{PersonMention, PersonMentionUpdateForm}, - traits::Crud, -}; -use lemmy_db_views::structs::LocalUserView; -use lemmy_db_views_actor::structs::PersonMentionView; -use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; - -#[tracing::instrument(skip(context))] -pub async fn mark_person_mention_as_read( - data: Json, - context: Data, - local_user_view: LocalUserView, -) -> LemmyResult> { - let person_mention_id = data.person_mention_id; - let read_person_mention = PersonMention::read(&mut context.pool(), person_mention_id).await?; - - if local_user_view.person.id != read_person_mention.recipient_id { - Err(LemmyErrorType::CouldntUpdateComment)? - } - - let person_mention_id = read_person_mention.id; - let read = Some(data.read); - PersonMention::update( - &mut context.pool(), - person_mention_id, - &PersonMentionUpdateForm { read }, - ) - .await - .with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?; - - let person_mention_id = read_person_mention.id; - let person_id = local_user_view.person.id; - let person_mention_view = - PersonMentionView::read(&mut context.pool(), person_mention_id, Some(person_id)).await?; - - Ok(Json(PersonMentionResponse { - person_mention_view, - })) -} diff --git a/crates/api/src/local_user/notifications/mark_post_mention_read.rs b/crates/api/src/local_user/notifications/mark_post_mention_read.rs new file mode 100644 index 000000000..954435cb7 --- /dev/null +++ b/crates/api/src/local_user/notifications/mark_post_mention_read.rs @@ -0,0 +1,39 @@ +use actix_web::web::{Data, Json}; +use lemmy_api_common::{ + context::LemmyContext, + person::MarkPersonPostMentionAsRead, + SuccessResponse, +}; +use lemmy_db_schema::{ + source::person_post_mention::{PersonPostMention, PersonPostMentionUpdateForm}, + traits::Crud, +}; +use lemmy_db_views::structs::LocalUserView; +use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; + +#[tracing::instrument(skip(context))] +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 + .with_lemmy_type(LemmyErrorType::CouldntUpdatePost)?; + + Ok(Json(SuccessResponse::default())) +} diff --git a/crates/api/src/local_user/notifications/mark_reply_read.rs b/crates/api/src/local_user/notifications/mark_reply_read.rs index 5b263145f..4a1017ce1 100644 --- a/crates/api/src/local_user/notifications/mark_reply_read.rs +++ b/crates/api/src/local_user/notifications/mark_reply_read.rs @@ -1,14 +1,10 @@ use actix_web::web::{Data, Json}; -use lemmy_api_common::{ - context::LemmyContext, - person::{CommentReplyResponse, MarkCommentReplyAsRead}, -}; +use lemmy_api_common::{context::LemmyContext, person::MarkCommentReplyAsRead, SuccessResponse}; use lemmy_db_schema::{ source::comment_reply::{CommentReply, CommentReplyUpdateForm}, traits::Crud, }; use lemmy_db_views::structs::LocalUserView; -use lemmy_db_views_actor::structs::CommentReplyView; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; #[tracing::instrument(skip(context))] @@ -16,7 +12,7 @@ pub async fn mark_reply_as_read( data: Json, context: Data, local_user_view: LocalUserView, -) -> LemmyResult> { +) -> LemmyResult> { let comment_reply_id = data.comment_reply_id; let read_comment_reply = CommentReply::read(&mut context.pool(), comment_reply_id).await?; @@ -35,10 +31,5 @@ pub async fn mark_reply_as_read( .await .with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?; - let comment_reply_id = read_comment_reply.id; - let person_id = local_user_view.person.id; - let comment_reply_view = - CommentReplyView::read(&mut context.pool(), comment_reply_id, Some(person_id)).await?; - - Ok(Json(CommentReplyResponse { comment_reply_view })) + Ok(Json(SuccessResponse::default())) } diff --git a/crates/api/src/local_user/notifications/mod.rs b/crates/api/src/local_user/notifications/mod.rs index 35567afde..9f2048d90 100644 --- a/crates/api/src/local_user/notifications/mod.rs +++ b/crates/api/src/local_user/notifications/mod.rs @@ -1,6 +1,6 @@ -pub mod list_mentions; -pub mod list_replies; +pub mod list_inbox; pub mod mark_all_read; -pub mod mark_mention_read; +pub mod mark_comment_mention_read; +pub mod mark_post_mention_read; pub mod mark_reply_read; pub mod unread_count; diff --git a/crates/api/src/local_user/notifications/unread_count.rs b/crates/api/src/local_user/notifications/unread_count.rs index 4c6c65263..4fa959329 100644 --- a/crates/api/src/local_user/notifications/unread_count.rs +++ b/crates/api/src/local_user/notifications/unread_count.rs @@ -1,7 +1,7 @@ use actix_web::web::{Data, Json}; use lemmy_api_common::{context::LemmyContext, person::GetUnreadCountResponse}; -use lemmy_db_views::structs::{LocalUserView, PrivateMessageView}; -use lemmy_db_views_actor::structs::{CommentReplyView, PersonMentionView}; +use lemmy_db_views::structs::LocalUserView; +use lemmy_db_views_actor::structs::InboxCombinedViewInternal; use lemmy_utils::error::LemmyResult; #[tracing::instrument(skip(context))] @@ -10,20 +10,10 @@ pub async fn unread_count( local_user_view: LocalUserView, ) -> LemmyResult> { let person_id = local_user_view.person.id; - - let replies = - CommentReplyView::get_unread_replies(&mut context.pool(), &local_user_view.local_user).await?; - - let mentions = - PersonMentionView::get_unread_mentions(&mut context.pool(), &local_user_view.local_user) + let show_bot_accounts = local_user_view.local_user.show_bot_accounts; + let count = + InboxCombinedViewInternal::get_unread_count(&mut context.pool(), person_id, show_bot_accounts) .await?; - let private_messages = - PrivateMessageView::get_unread_messages(&mut context.pool(), person_id).await?; - - Ok(Json(GetUnreadCountResponse { - replies, - mentions, - private_messages, - })) + Ok(Json(GetUnreadCountResponse { count })) } diff --git a/crates/api/src/post/like.rs b/crates/api/src/post/like.rs index 031e3f0db..6555228e9 100644 --- a/crates/api/src/post/like.rs +++ b/crates/api/src/post/like.rs @@ -5,9 +5,10 @@ use lemmy_api_common::{ context::LemmyContext, post::{CreatePostLike, PostResponse}, send_activity::{ActivityChannel, SendActivityData}, - utils::{check_bot_account, check_community_user_action, check_local_vote_mode, VoteItem}, + utils::{check_bot_account, check_community_user_action, check_local_vote_mode}, }; use lemmy_db_schema::{ + newtypes::PostOrCommentId, source::{ local_site::LocalSite, post::{PostLike, PostLikeForm, PostRead, PostReadForm}, @@ -29,7 +30,7 @@ pub async fn like_post( check_local_vote_mode( data.score, - VoteItem::Post(post_id), + PostOrCommentId::Post(post_id), &local_site, local_user_view.person.id, &mut context.pool(), diff --git a/crates/api/src/private_message/mark_read.rs b/crates/api/src/private_message/mark_read.rs index 7c213464b..128228d6d 100644 --- a/crates/api/src/private_message/mark_read.rs +++ b/crates/api/src/private_message/mark_read.rs @@ -1,13 +1,14 @@ use actix_web::web::{Data, Json}; use lemmy_api_common::{ context::LemmyContext, - private_message::{MarkPrivateMessageAsRead, PrivateMessageResponse}, + private_message::MarkPrivateMessageAsRead, + SuccessResponse, }; use lemmy_db_schema::{ source::private_message::{PrivateMessage, PrivateMessageUpdateForm}, traits::Crud, }; -use lemmy_db_views::structs::{LocalUserView, PrivateMessageView}; +use lemmy_db_views::structs::LocalUserView; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; #[tracing::instrument(skip(context))] @@ -15,7 +16,7 @@ pub async fn mark_pm_as_read( data: Json, context: Data, local_user_view: LocalUserView, -) -> LemmyResult> { +) -> LemmyResult> { // Checking permissions let private_message_id = data.private_message_id; let orig_private_message = PrivateMessage::read(&mut context.pool(), private_message_id).await?; @@ -37,8 +38,5 @@ pub async fn mark_pm_as_read( .await .with_lemmy_type(LemmyErrorType::CouldntUpdatePrivateMessage)?; - let view = PrivateMessageView::read(&mut context.pool(), private_message_id).await?; - Ok(Json(PrivateMessageResponse { - private_message_view: view, - })) + Ok(Json(SuccessResponse::default())) } diff --git a/crates/api_common/src/build_response.rs b/crates/api_common/src/build_response.rs index b73c0e482..0245a0459 100644 --- a/crates/api_common/src/build_response.rs +++ b/crates/api_common/src/build_response.rs @@ -12,14 +12,15 @@ use crate::{ }; use actix_web::web::Json; use lemmy_db_schema::{ - newtypes::{CommentId, CommunityId, LocalUserId, PostId}, + newtypes::{CommentId, CommunityId, LocalUserId, PostId, PostOrCommentId}, source::{ actor_language::CommunityLanguage, comment::Comment, comment_reply::{CommentReply, CommentReplyInsertForm}, community::Community, person::Person, - person_mention::{PersonMention, PersonMentionInsertForm}, + person_comment_mention::{PersonCommentMention, PersonCommentMentionInsertForm}, + person_post_mention::{PersonPostMention, PersonPostMentionInsertForm}, post::Post, }, traits::Crud, @@ -94,7 +95,7 @@ pub async fn build_post_response( #[tracing::instrument(skip_all)] pub async fn send_local_notifs( mentions: Vec, - comment_id: CommentId, + post_or_comment_id: PostOrCommentId, person: &Person, do_send_email: bool, context: &LemmyContext, @@ -103,27 +104,42 @@ pub async fn send_local_notifs( let mut recipient_ids = Vec::new(); let inbox_link = format!("{}/inbox", context.settings().get_protocol_and_hostname()); - // 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. - let (comment, post, community) = if let Some(local_user_view) = local_user_view { - let comment_view = CommentView::read( - &mut context.pool(), - comment_id, - Some(&local_user_view.local_user), - ) - .await?; - ( - 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?; - (comment, post, community) + 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), + 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), + ) + .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 @@ -140,22 +156,38 @@ pub async fn send_local_notifs( // below by checking recipient ids recipient_ids.push(mention_user_view.local_user.id); - let user_mention_form = PersonMentionInsertForm { - recipient_id: mention_user_view.person.id, - comment_id, - read: None, - }; + // Make the correct reply form depending on whether its a post or comment mention + let 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 - PersonMention::create(&mut context.pool(), &user_mention_form) - .await - .ok(); + // 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.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.body.clone().unwrap_or_default() + }; // Send an email to those local users that have notifications on if do_send_email { let lang = get_interface_language(&mention_user_view); - let content = markdown_to_html(&comment.content); + let content = markdown_to_html(&comment_content_or_post_body); send_email_to_user( &mention_user_view, &lang.notification_mentioned_by_subject(&person.name), @@ -168,99 +200,101 @@ pub async fn send_local_notifs( } // Send comment_reply to the parent commenter / poster - if let Some(parent_comment_id) = comment.parent_comment_id() { - let parent_comment = Comment::read(&mut context.pool(), parent_comment_id).await?; + 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; + // 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(); + 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); + // 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, - }; + 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(); + // 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 { - let lang = get_interface_language(&parent_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(&content, &inbox_link, &person.name), - context.settings(), - ) - .await + if do_send_email { + let lang = get_interface_language(&parent_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(&content, &inbox_link, &person.name), + 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(); + } 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); + 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, - }; + 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(); + // 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 { - let lang = get_interface_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(&content, &inbox_link, &person.name), - context.settings(), - ) - .await + if do_send_email { + let lang = get_interface_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(&content, &inbox_link, &person.name), + context.settings(), + ) + .await + } } } } diff --git a/crates/api_common/src/person.rs b/crates/api_common/src/person.rs index 2df3a7162..b4a294bf5 100644 --- a/crates/api_common/src/person.rs +++ b/crates/api_common/src/person.rs @@ -1,8 +1,16 @@ use lemmy_db_schema::{ - newtypes::{CommentReplyId, CommunityId, LanguageId, PersonId, PersonMentionId}, + newtypes::{ + CommentReplyId, + CommunityId, + LanguageId, + PersonCommentMentionId, + PersonId, + PersonPostMentionId, + }, sensitive::SensitiveString, source::{login_token::LoginToken, site::Site}, CommentSortType, + InboxDataType, ListingType, PersonContentType, PostListingMode, @@ -15,9 +23,9 @@ use lemmy_db_views::structs::{ PersonSavedCombinedPaginationCursor, }; use lemmy_db_views_actor::structs::{ - CommentReplyView, CommunityModeratorView, - PersonMentionView, + InboxCombinedPaginationCursor, + InboxCombinedView, PersonView, }; use serde::{Deserialize, Serialize}; @@ -364,69 +372,45 @@ pub struct BlockPersonResponse { } #[skip_serializing_none] -#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "full", derive(TS))] #[cfg_attr(feature = "full", ts(export))] -/// Get comment replies. -pub struct GetReplies { +/// Get your inbox (replies, comment mentions, post mentions, and messages) +pub struct ListInbox { #[cfg_attr(feature = "full", ts(optional))] - pub sort: Option, - #[cfg_attr(feature = "full", ts(optional))] - pub page: Option, - #[cfg_attr(feature = "full", ts(optional))] - pub limit: Option, + pub type_: Option, #[cfg_attr(feature = "full", ts(optional))] pub unread_only: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone, Default)] -#[cfg_attr(feature = "full", derive(TS))] -#[cfg_attr(feature = "full", ts(export))] -/// Fetches your replies. -// TODO, replies and mentions below should be redone as tagged enums. -pub struct GetRepliesResponse { - pub replies: Vec, -} - -#[skip_serializing_none] -#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "full", derive(TS))] -#[cfg_attr(feature = "full", ts(export))] -/// Get mentions for your user. -pub struct GetPersonMentions { #[cfg_attr(feature = "full", ts(optional))] - pub sort: Option, + pub page_cursor: Option, #[cfg_attr(feature = "full", ts(optional))] - pub page: Option, - #[cfg_attr(feature = "full", ts(optional))] - pub limit: Option, - #[cfg_attr(feature = "full", ts(optional))] - pub unread_only: Option, + pub page_back: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(TS))] #[cfg_attr(feature = "full", ts(export))] -/// The response of mentions for your user. -pub struct GetPersonMentionsResponse { - pub mentions: Vec, +/// Get your inbox (replies, comment mentions, post mentions, and messages) +pub struct ListInboxResponse { + pub inbox: Vec, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "full", derive(TS))] #[cfg_attr(feature = "full", ts(export))] /// Mark a person mention as read. -pub struct MarkPersonMentionAsRead { - pub person_mention_id: PersonMentionId, +pub struct MarkPersonCommentMentionAsRead { + pub person_comment_mention_id: PersonCommentMentionId, pub read: bool, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "full", derive(TS))] #[cfg_attr(feature = "full", ts(export))] -/// The response for a person mention action. -pub struct PersonMentionResponse { - pub person_mention_view: PersonMentionView, +/// Mark a person mention as read. +pub struct MarkPersonPostMentionAsRead { + pub person_post_mention_id: PersonPostMentionId, + pub read: bool, } #[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] @@ -438,14 +422,6 @@ pub struct MarkCommentReplyAsRead { pub read: bool, } -#[derive(Debug, Serialize, Deserialize, Clone)] -#[cfg_attr(feature = "full", derive(TS))] -#[cfg_attr(feature = "full", ts(export))] -/// The response for a comment reply action. -pub struct CommentReplyResponse { - pub comment_reply_view: CommentReplyView, -} - #[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "full", derive(TS))] #[cfg_attr(feature = "full", ts(export))] @@ -495,11 +471,9 @@ pub struct GetReportCountResponse { #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(TS))] #[cfg_attr(feature = "full", ts(export))] -/// A response containing counts for your notifications. +/// A response containing a count of unread notifications. pub struct GetUnreadCountResponse { - pub replies: i64, - pub mentions: i64, - pub private_messages: i64, + pub count: i64, } #[derive(Serialize, Deserialize, Clone, Default, Debug, PartialEq, Eq, Hash)] diff --git a/crates/api_common/src/private_message.rs b/crates/api_common/src/private_message.rs index 8bd417a8e..f8134ea27 100644 --- a/crates/api_common/src/private_message.rs +++ b/crates/api_common/src/private_message.rs @@ -1,7 +1,6 @@ use lemmy_db_schema::newtypes::{PersonId, PrivateMessageId}; -use lemmy_db_views::structs::PrivateMessageView; +use lemmy_db_views_actor::structs::PrivateMessageView; use serde::{Deserialize, Serialize}; -use serde_with::skip_serializing_none; #[cfg(feature = "full")] use ts_rs::TS; @@ -41,30 +40,6 @@ pub struct MarkPrivateMessageAsRead { pub read: bool, } -#[skip_serializing_none] -#[derive(Debug, Serialize, Deserialize, Clone, Copy, Default, PartialEq, Eq, Hash)] -#[cfg_attr(feature = "full", derive(TS))] -#[cfg_attr(feature = "full", ts(export))] -/// Get your private messages. -pub struct GetPrivateMessages { - #[cfg_attr(feature = "full", ts(optional))] - pub unread_only: Option, - #[cfg_attr(feature = "full", ts(optional))] - pub page: Option, - #[cfg_attr(feature = "full", ts(optional))] - pub limit: Option, - #[cfg_attr(feature = "full", ts(optional))] - pub creator_id: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -#[cfg_attr(feature = "full", derive(TS))] -#[cfg_attr(feature = "full", ts(export))] -/// The private messages response. -pub struct PrivateMessagesResponse { - pub private_messages: Vec, -} - #[derive(Debug, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(TS))] #[cfg_attr(feature = "full", ts(export))] diff --git a/crates/api_common/src/send_activity.rs b/crates/api_common/src/send_activity.rs index b606c9a90..07203ffe4 100644 --- a/crates/api_common/src/send_activity.rs +++ b/crates/api_common/src/send_activity.rs @@ -11,7 +11,7 @@ use lemmy_db_schema::{ private_message::PrivateMessage, }, }; -use lemmy_db_views::structs::PrivateMessageView; +use lemmy_db_views_actor::structs::PrivateMessageView; use lemmy_utils::error::LemmyResult; use std::sync::{LazyLock, OnceLock}; use tokio::{ diff --git a/crates/api_common/src/utils.rs b/crates/api_common/src/utils.rs index e508e04da..528f408d2 100644 --- a/crates/api_common/src/utils.rs +++ b/crates/api_common/src/utils.rs @@ -11,7 +11,7 @@ use chrono::{DateTime, Days, Local, TimeZone, Utc}; use enum_map::{enum_map, EnumMap}; use lemmy_db_schema::{ aggregates::structs::{PersonPostAggregates, PersonPostAggregatesForm}, - newtypes::{CommentId, CommunityId, DbUrl, InstanceId, PersonId, PostId}, + newtypes::{CommentId, CommunityId, DbUrl, InstanceId, PersonId, PostId, PostOrCommentId}, source::{ comment::{Comment, CommentLike, CommentUpdateForm}, community::{Community, CommunityModerator, CommunityUpdateForm}, @@ -291,23 +291,17 @@ pub async fn check_person_instance_community_block( Ok(()) } -/// A vote item type used to check the vote mode. -pub enum VoteItem { - Post(PostId), - Comment(CommentId), -} - #[tracing::instrument(skip_all)] pub async fn check_local_vote_mode( score: i16, - vote_item: VoteItem, + post_or_comment_id: PostOrCommentId, local_site: &LocalSite, person_id: PersonId, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { - let (downvote_setting, upvote_setting) = match vote_item { - VoteItem::Post(_) => (local_site.post_downvotes, local_site.post_upvotes), - VoteItem::Comment(_) => (local_site.comment_downvotes, local_site.comment_upvotes), + let (downvote_setting, upvote_setting) = match post_or_comment_id { + PostOrCommentId::Post(_) => (local_site.post_downvotes, local_site.post_upvotes), + PostOrCommentId::Comment(_) => (local_site.comment_downvotes, local_site.comment_upvotes), }; let downvote_fail = score == -1 && downvote_setting == FederationMode::Disable; @@ -315,9 +309,11 @@ pub async fn check_local_vote_mode( // Undo previous vote for item if new vote fails if downvote_fail || upvote_fail { - match vote_item { - VoteItem::Post(post_id) => PostLike::remove(pool, person_id, post_id).await?, - VoteItem::Comment(comment_id) => CommentLike::remove(pool, person_id, comment_id).await?, + match post_or_comment_id { + PostOrCommentId::Post(post_id) => PostLike::remove(pool, person_id, post_id).await?, + PostOrCommentId::Comment(comment_id) => { + CommentLike::remove(pool, person_id, comment_id).await? + } }; } Ok(()) diff --git a/crates/api_crud/src/comment/create.rs b/crates/api_crud/src/comment/create.rs index edcf7db30..692b85c17 100644 --- a/crates/api_crud/src/comment/create.rs +++ b/crates/api_crud/src/comment/create.rs @@ -17,11 +17,12 @@ use lemmy_api_common::{ }; use lemmy_db_schema::{ impls::actor_language::validate_post_language, + newtypes::PostOrCommentId, source::{ comment::{Comment, CommentInsertForm, CommentLike, CommentLikeForm}, comment_reply::{CommentReply, CommentReplyUpdateForm}, local_site::LocalSite, - person_mention::{PersonMention, PersonMentionUpdateForm}, + person_comment_mention::{PersonCommentMention, PersonCommentMentionUpdateForm}, }, traits::{Crud, Likeable}, }; @@ -117,7 +118,7 @@ pub async fn create_comment( let mentions = scrape_text_for_mentions(&content); let recipient_ids = send_local_notifs( mentions, - inserted_comment_id, + PostOrCommentId::Comment(inserted_comment_id), &local_user_view.person, true, &context, @@ -169,17 +170,18 @@ pub async fn create_comment( .with_lemmy_type(LemmyErrorType::CouldntUpdateReplies)?; } - // If the parent has PersonMentions mark them as read too - let person_mention = - PersonMention::read_by_comment_and_person(&mut context.pool(), parent_id, person_id).await; - if let Ok(Some(mention)) = person_mention { - PersonMention::update( + // 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, - &PersonMentionUpdateForm { read: Some(true) }, + &PersonCommentMentionUpdateForm { read: Some(true) }, ) .await - .with_lemmy_type(LemmyErrorType::CouldntUpdatePersonMentions)?; + .with_lemmy_type(LemmyErrorType::CouldntUpdatePersonCommentMentions)?; } } diff --git a/crates/api_crud/src/comment/delete.rs b/crates/api_crud/src/comment/delete.rs index 60a22a2ef..cf90df6b6 100644 --- a/crates/api_crud/src/comment/delete.rs +++ b/crates/api_crud/src/comment/delete.rs @@ -8,6 +8,7 @@ use lemmy_api_common::{ utils::check_community_user_action, }; use lemmy_db_schema::{ + newtypes::PostOrCommentId, source::comment::{Comment, CommentUpdateForm}, traits::Crud, }; @@ -60,7 +61,7 @@ pub async fn delete_comment( let recipient_ids = send_local_notifs( vec![], - comment_id, + PostOrCommentId::Comment(comment_id), &local_user_view.person, false, &context, diff --git a/crates/api_crud/src/comment/remove.rs b/crates/api_crud/src/comment/remove.rs index 1ac6201e8..4436f8c87 100644 --- a/crates/api_crud/src/comment/remove.rs +++ b/crates/api_crud/src/comment/remove.rs @@ -8,6 +8,7 @@ use lemmy_api_common::{ utils::check_community_mod_action, }; use lemmy_db_schema::{ + newtypes::PostOrCommentId, source::{ comment::{Comment, CommentUpdateForm}, comment_report::CommentReport, @@ -82,7 +83,7 @@ pub async fn remove_comment( let recipient_ids = send_local_notifs( vec![], - comment_id, + PostOrCommentId::Comment(comment_id), &local_user_view.person, false, &context, diff --git a/crates/api_crud/src/comment/update.rs b/crates/api_crud/src/comment/update.rs index 1af026204..3cb1a3a4e 100644 --- a/crates/api_crud/src/comment/update.rs +++ b/crates/api_crud/src/comment/update.rs @@ -15,6 +15,7 @@ use lemmy_api_common::{ }; use lemmy_db_schema::{ impls::actor_language::validate_post_language, + newtypes::PostOrCommentId, source::{ comment::{Comment, CommentUpdateForm}, local_site::LocalSite, @@ -86,7 +87,7 @@ pub async fn update_comment( let mentions = scrape_text_for_mentions(&updated_comment_content); let recipient_ids = send_local_notifs( mentions, - comment_id, + PostOrCommentId::Comment(comment_id), &local_user_view.person, false, &context, diff --git a/crates/api_crud/src/post/create.rs b/crates/api_crud/src/post/create.rs index e9e11bc16..639ac57e5 100644 --- a/crates/api_crud/src/post/create.rs +++ b/crates/api_crud/src/post/create.rs @@ -2,7 +2,7 @@ use super::convert_published_time; use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_common::{ - build_response::build_post_response, + build_response::{build_post_response, send_local_notifs}, context::LemmyContext, post::{CreatePost, PostResponse}, request::generate_post_link_metadata, @@ -17,6 +17,7 @@ use lemmy_api_common::{ }; use lemmy_db_schema::{ impls::actor_language::validate_post_language, + newtypes::PostOrCommentId, source::{ community::Community, local_site::LocalSite, @@ -32,6 +33,7 @@ use lemmy_utils::{ error::{LemmyErrorExt, LemmyErrorType, LemmyResult}, spawn_try_task, utils::{ + mention::scrape_text_for_mentions, slurs::check_slurs, validation::{ is_url_blocked, @@ -151,6 +153,18 @@ pub async fn create_post( .await .with_lemmy_type(LemmyErrorType::CouldntLikePost)?; + // Scan the post body for user mentions, add those rows + let mentions = scrape_text_for_mentions(&inserted_post.body.clone().unwrap_or_default()); + send_local_notifs( + mentions, + PostOrCommentId::Post(inserted_post.id), + &local_user_view.person, + true, + &context, + Some(&local_user_view), + ) + .await?; + let read_form = PostReadForm::new(post_id, person_id); PostRead::mark_as_read(&mut context.pool(), &read_form).await?; diff --git a/crates/api_crud/src/post/update.rs b/crates/api_crud/src/post/update.rs index 24bbed009..a93708b22 100644 --- a/crates/api_crud/src/post/update.rs +++ b/crates/api_crud/src/post/update.rs @@ -3,7 +3,7 @@ use activitypub_federation::config::Data; use actix_web::web::Json; use chrono::Utc; use lemmy_api_common::{ - build_response::build_post_response, + build_response::{build_post_response, send_local_notifs}, context::LemmyContext, post::{EditPost, PostResponse}, request::generate_post_link_metadata, @@ -17,6 +17,7 @@ use lemmy_api_common::{ }; use lemmy_db_schema::{ impls::actor_language::validate_post_language, + newtypes::PostOrCommentId, source::{ community::Community, local_site::LocalSite, @@ -29,6 +30,7 @@ use lemmy_db_views::structs::{LocalUserView, PostView}; use lemmy_utils::{ error::{LemmyErrorExt, LemmyErrorType, LemmyResult}, utils::{ + mention::scrape_text_for_mentions, slurs::check_slurs, validation::{ is_url_blocked, @@ -142,6 +144,18 @@ pub async fn update_post( .await .with_lemmy_type(LemmyErrorType::CouldntUpdatePost)?; + // 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), + &local_user_view.person, + false, + &context, + Some(&local_user_view), + ) + .await?; + // send out federation/webmention if necessary match ( orig_post.post.scheduled_publish_time, diff --git a/crates/api_crud/src/private_message/create.rs b/crates/api_crud/src/private_message/create.rs index 1a6a78d00..fd95a2b9e 100644 --- a/crates/api_crud/src/private_message/create.rs +++ b/crates/api_crud/src/private_message/create.rs @@ -21,7 +21,8 @@ use lemmy_db_schema::{ }, traits::Crud, }; -use lemmy_db_views::structs::{LocalUserView, PrivateMessageView}; +use lemmy_db_views::structs::LocalUserView; +use lemmy_db_views_actor::structs::PrivateMessageView; use lemmy_utils::{ error::{LemmyErrorExt, LemmyErrorType, LemmyResult}, utils::{markdown::markdown_to_html, validation::is_valid_body_field}, diff --git a/crates/api_crud/src/private_message/delete.rs b/crates/api_crud/src/private_message/delete.rs index 30efc020c..d06c8bc04 100644 --- a/crates/api_crud/src/private_message/delete.rs +++ b/crates/api_crud/src/private_message/delete.rs @@ -9,7 +9,8 @@ use lemmy_db_schema::{ source::private_message::{PrivateMessage, PrivateMessageUpdateForm}, traits::Crud, }; -use lemmy_db_views::structs::{LocalUserView, PrivateMessageView}; +use lemmy_db_views::structs::LocalUserView; +use lemmy_db_views_actor::structs::PrivateMessageView; use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; #[tracing::instrument(skip(context))] diff --git a/crates/api_crud/src/private_message/mod.rs b/crates/api_crud/src/private_message/mod.rs index ab7fa4390..fdb2f5561 100644 --- a/crates/api_crud/src/private_message/mod.rs +++ b/crates/api_crud/src/private_message/mod.rs @@ -1,4 +1,3 @@ pub mod create; pub mod delete; -pub mod read; pub mod update; diff --git a/crates/api_crud/src/private_message/read.rs b/crates/api_crud/src/private_message/read.rs deleted file mode 100644 index 7558b97fc..000000000 --- a/crates/api_crud/src/private_message/read.rs +++ /dev/null @@ -1,33 +0,0 @@ -use actix_web::web::{Data, Json, Query}; -use lemmy_api_common::{ - context::LemmyContext, - private_message::{GetPrivateMessages, PrivateMessagesResponse}, -}; -use lemmy_db_views::{private_message_view::PrivateMessageQuery, structs::LocalUserView}; -use lemmy_utils::error::LemmyResult; - -#[tracing::instrument(skip(context))] -pub async fn get_private_message( - data: Query, - context: Data, - local_user_view: LocalUserView, -) -> LemmyResult> { - let person_id = local_user_view.person.id; - - let page = data.page; - let limit = data.limit; - let unread_only = data.unread_only.unwrap_or_default(); - let creator_id = data.creator_id; - let messages = PrivateMessageQuery { - page, - limit, - unread_only, - creator_id, - } - .list(&mut context.pool(), person_id) - .await?; - - Ok(Json(PrivateMessagesResponse { - private_messages: messages, - })) -} diff --git a/crates/api_crud/src/private_message/update.rs b/crates/api_crud/src/private_message/update.rs index b9e4785ef..22c1da4a2 100644 --- a/crates/api_crud/src/private_message/update.rs +++ b/crates/api_crud/src/private_message/update.rs @@ -14,7 +14,8 @@ use lemmy_db_schema::{ }, traits::Crud, }; -use lemmy_db_views::structs::{LocalUserView, PrivateMessageView}; +use lemmy_db_views::structs::LocalUserView; +use lemmy_db_views_actor::structs::PrivateMessageView; use lemmy_utils::{ error::{LemmyErrorExt, LemmyErrorType, LemmyResult}, utils::validation::is_valid_body_field, diff --git a/crates/apub/src/activities/create_or_update/comment.rs b/crates/apub/src/activities/create_or_update/comment.rs index f8d2a6d71..a363e60a6 100644 --- a/crates/apub/src/activities/create_or_update/comment.rs +++ b/crates/apub/src/activities/create_or_update/comment.rs @@ -29,7 +29,7 @@ use lemmy_api_common::{ }; use lemmy_db_schema::{ aggregates::structs::CommentAggregates, - newtypes::PersonId, + newtypes::{PersonId, PostOrCommentId}, source::{ activity::ActivitySendTargets, comment::{Comment, CommentLike, CommentLikeForm}, @@ -175,10 +175,17 @@ impl ActivityHandler for CreateOrUpdateNote { // 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, comment.id, &actor, do_send_email, context, None).await?; + send_local_notifs( + mentions, + PostOrCommentId::Comment(comment.id), + &actor, + do_send_email, + context, + None, + ) + .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 80a95f4be..0aa0faf0b 100644 --- a/crates/apub/src/activities/create_or_update/post.rs +++ b/crates/apub/src/activities/create_or_update/post.rs @@ -20,10 +20,10 @@ use activitypub_federation::{ protocol::verification::{verify_domains_match, verify_urls_match}, traits::{ActivityHandler, Actor, Object}, }; -use lemmy_api_common::context::LemmyContext; +use lemmy_api_common::{build_response::send_local_notifs, context::LemmyContext}; use lemmy_db_schema::{ aggregates::structs::PostAggregates, - newtypes::PersonId, + newtypes::{PersonId, PostOrCommentId}, source::{ activity::ActivitySendTargets, community::Community, @@ -32,7 +32,10 @@ use lemmy_db_schema::{ }, traits::{Crud, Likeable}, }; -use lemmy_utils::error::{LemmyError, LemmyResult}; +use lemmy_utils::{ + error::{LemmyError, LemmyResult}, + utils::mention::scrape_text_for_mentions, +}; use url::Url; impl CreateOrUpdatePage { @@ -123,6 +126,21 @@ impl ActivityHandler for CreateOrUpdatePage { // Calculate initial hot_rank for post PostAggregates::update_ranks(&mut context.pool(), post.id).await?; + let do_send_email = self.kind == CreateOrUpdateType::Create; + 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, + ) + .await?; + Ok(()) } } diff --git a/crates/apub/src/activities/create_or_update/private_message.rs b/crates/apub/src/activities/create_or_update/private_message.rs index b6e7478ef..ce04a9330 100644 --- a/crates/apub/src/activities/create_or_update/private_message.rs +++ b/crates/apub/src/activities/create_or_update/private_message.rs @@ -14,7 +14,7 @@ use activitypub_federation::{ }; use lemmy_api_common::context::LemmyContext; use lemmy_db_schema::source::activity::ActivitySendTargets; -use lemmy_db_views::structs::PrivateMessageView; +use lemmy_db_views_actor::structs::PrivateMessageView; use lemmy_utils::error::{LemmyError, LemmyResult}; use url::Url; diff --git a/crates/db_schema/replaceable_schema/triggers.sql b/crates/db_schema/replaceable_schema/triggers.sql index 29ba4c682..c0cbfab34 100644 --- a/crates/db_schema/replaceable_schema/triggers.sql +++ b/crates/db_schema/replaceable_schema/triggers.sql @@ -857,3 +857,35 @@ 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, thing_id) + VALUES (NEW.published, 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'); + diff --git a/crates/db_schema/src/impls/mod.rs b/crates/db_schema/src/impls/mod.rs index 37e16c254..f6a01f06a 100644 --- a/crates/db_schema/src/impls/mod.rs +++ b/crates/db_schema/src/impls/mod.rs @@ -28,7 +28,8 @@ pub mod oauth_provider; pub mod password_reset_request; pub mod person; pub mod person_block; -pub mod person_mention; +pub mod person_comment_mention; +pub mod person_post_mention; pub mod post; pub mod post_report; pub mod private_message; diff --git a/crates/db_schema/src/impls/person_comment_mention.rs b/crates/db_schema/src/impls/person_comment_mention.rs new file mode 100644 index 000000000..2cc303396 --- /dev/null +++ b/crates/db_schema/src/impls/person_comment_mention.rs @@ -0,0 +1,83 @@ +use crate::{ + diesel::OptionalExtension, + newtypes::{CommentId, PersonCommentMentionId, PersonId}, + schema::person_comment_mention, + source::person_comment_mention::{ + PersonCommentMention, + PersonCommentMentionInsertForm, + PersonCommentMentionUpdateForm, + }, + traits::Crud, + utils::{get_conn, DbPool}, +}; +use diesel::{dsl::insert_into, result::Error, ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; + +#[async_trait] +impl Crud for PersonCommentMention { + type InsertForm = PersonCommentMentionInsertForm; + type UpdateForm = PersonCommentMentionUpdateForm; + type IdType = PersonCommentMentionId; + + async fn create( + pool: &mut DbPool<'_>, + person_comment_mention_form: &Self::InsertForm, + ) -> Result { + 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(person_comment_mention_form) + .on_conflict(( + person_comment_mention::recipient_id, + person_comment_mention::comment_id, + )) + .do_update() + .set(person_comment_mention_form) + .get_result::(conn) + .await + } + + async fn update( + pool: &mut DbPool<'_>, + person_comment_mention_id: PersonCommentMentionId, + person_comment_mention_form: &Self::UpdateForm, + ) -> Result { + 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 + } +} + +impl PersonCommentMention { + pub async fn mark_all_as_read( + pool: &mut DbPool<'_>, + for_recipient_id: PersonId, + ) -> Result, Error> { + 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 + } + + pub async fn read_by_comment_and_person( + pool: &mut DbPool<'_>, + for_comment_id: CommentId, + for_recipient_id: PersonId, + ) -> Result, Error> { + 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() + } +} diff --git a/crates/db_schema/src/impls/person_mention.rs b/crates/db_schema/src/impls/person_mention.rs deleted file mode 100644 index 433176683..000000000 --- a/crates/db_schema/src/impls/person_mention.rs +++ /dev/null @@ -1,76 +0,0 @@ -use crate::{ - diesel::OptionalExtension, - newtypes::{CommentId, PersonId, PersonMentionId}, - schema::person_mention, - source::person_mention::{PersonMention, PersonMentionInsertForm, PersonMentionUpdateForm}, - traits::Crud, - utils::{get_conn, DbPool}, -}; -use diesel::{dsl::insert_into, result::Error, ExpressionMethods, QueryDsl}; -use diesel_async::RunQueryDsl; - -#[async_trait] -impl Crud for PersonMention { - type InsertForm = PersonMentionInsertForm; - type UpdateForm = PersonMentionUpdateForm; - type IdType = PersonMentionId; - - async fn create( - pool: &mut DbPool<'_>, - person_mention_form: &Self::InsertForm, - ) -> Result { - 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_mention::table) - .values(person_mention_form) - .on_conflict((person_mention::recipient_id, person_mention::comment_id)) - .do_update() - .set(person_mention_form) - .get_result::(conn) - .await - } - - async fn update( - pool: &mut DbPool<'_>, - person_mention_id: PersonMentionId, - person_mention_form: &Self::UpdateForm, - ) -> Result { - let conn = &mut get_conn(pool).await?; - diesel::update(person_mention::table.find(person_mention_id)) - .set(person_mention_form) - .get_result::(conn) - .await - } -} - -impl PersonMention { - pub async fn mark_all_as_read( - pool: &mut DbPool<'_>, - for_recipient_id: PersonId, - ) -> Result, Error> { - let conn = &mut get_conn(pool).await?; - diesel::update( - person_mention::table - .filter(person_mention::recipient_id.eq(for_recipient_id)) - .filter(person_mention::read.eq(false)), - ) - .set(person_mention::read.eq(true)) - .get_results::(conn) - .await - } - - pub async fn read_by_comment_and_person( - pool: &mut DbPool<'_>, - for_comment_id: CommentId, - for_recipient_id: PersonId, - ) -> Result, Error> { - let conn = &mut get_conn(pool).await?; - person_mention::table - .filter(person_mention::comment_id.eq(for_comment_id)) - .filter(person_mention::recipient_id.eq(for_recipient_id)) - .first(conn) - .await - .optional() - } -} diff --git a/crates/db_schema/src/impls/person_post_mention.rs b/crates/db_schema/src/impls/person_post_mention.rs new file mode 100644 index 000000000..ef59b60e1 --- /dev/null +++ b/crates/db_schema/src/impls/person_post_mention.rs @@ -0,0 +1,83 @@ +use crate::{ + diesel::OptionalExtension, + newtypes::{PersonId, PersonPostMentionId, PostId}, + schema::person_post_mention, + source::person_post_mention::{ + PersonPostMention, + PersonPostMentionInsertForm, + PersonPostMentionUpdateForm, + }, + traits::Crud, + utils::{get_conn, DbPool}, +}; +use diesel::{dsl::insert_into, result::Error, ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; + +#[async_trait] +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, + ) -> Result { + 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 + } + + async fn update( + pool: &mut DbPool<'_>, + person_post_mention_id: PersonPostMentionId, + person_post_mention_form: &Self::UpdateForm, + ) -> Result { + 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 + } +} + +impl PersonPostMention { + pub async fn mark_all_as_read( + pool: &mut DbPool<'_>, + for_recipient_id: PersonId, + ) -> Result, Error> { + 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 + } + + pub async fn read_by_post_and_person( + pool: &mut DbPool<'_>, + for_post_id: PostId, + for_recipient_id: PersonId, + ) -> Result, Error> { + 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() + } +} diff --git a/crates/db_schema/src/lib.rs b/crates/db_schema/src/lib.rs index 4cf0c5030..c077f758b 100644 --- a/crates/db_schema/src/lib.rs +++ b/crates/db_schema/src/lib.rs @@ -214,6 +214,18 @@ pub enum ModlogActionType { AdminAllowInstance, } +#[derive(EnumString, Display, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +/// A list of possible types for the inbox. +pub enum InboxDataType { + All, + CommentReply, + CommentMention, + PostMention, + PrivateMessage, +} + #[derive(EnumString, Display, Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] #[cfg_attr(feature = "full", derive(TS))] #[cfg_attr(feature = "full", ts(export))] diff --git a/crates/db_schema/src/newtypes.rs b/crates/db_schema/src/newtypes.rs index 75731974e..66a02a79b 100644 --- a/crates/db_schema/src/newtypes.rs +++ b/crates/db_schema/src/newtypes.rs @@ -55,6 +55,11 @@ impl fmt::Display for CommentId { } } +pub enum PostOrCommentId { + Post(PostId), + Comment(CommentId), +} + #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(DieselNewType, TS))] #[cfg_attr(feature = "full", ts(export))] @@ -71,7 +76,7 @@ pub struct LocalUserId(pub i32); #[cfg_attr(feature = "full", derive(DieselNewType, TS))] #[cfg_attr(feature = "full", ts(export))] /// The private message id. -pub struct PrivateMessageId(i32); +pub struct PrivateMessageId(pub i32); impl fmt::Display for PrivateMessageId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -82,8 +87,14 @@ impl fmt::Display for PrivateMessageId { #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType, TS))] #[cfg_attr(feature = "full", ts(export))] -/// The person mention id. -pub struct PersonMentionId(i32); +/// 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, TS))] +#[cfg_attr(feature = "full", ts(export))] +/// The person post mention id. +pub struct PersonPostMentionId(pub i32); #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType, TS))] @@ -125,7 +136,7 @@ pub struct LanguageId(pub i32); #[cfg_attr(feature = "full", derive(DieselNewType, TS))] #[cfg_attr(feature = "full", ts(export))] /// The comment reply id. -pub struct CommentReplyId(i32); +pub struct CommentReplyId(pub i32); #[derive( Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default, Ord, PartialOrd, @@ -204,6 +215,11 @@ pub struct PersonSavedCombinedId(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))] +/// The inbox combined id +pub struct InboxCombinedId(i32); + #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize, Default)] #[cfg_attr(feature = "full", derive(DieselNewType, TS))] #[cfg_attr(feature = "full", ts(export))] diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs index 30e4550ef..d8364a2cb 100644 --- a/crates/db_schema/src/schema.rs +++ b/crates/db_schema/src/schema.rs @@ -351,6 +351,17 @@ diesel::table! { } } +diesel::table! { + inbox_combined (id) { + id -> Int4, + published -> 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, @@ -773,6 +784,16 @@ diesel::table! { } } +diesel::table! { + person_comment_mention (id) { + id -> Int4, + recipient_id -> Int4, + comment_id -> Int4, + read -> Bool, + published -> Timestamptz, + } +} + diesel::table! { person_content_combined (id) { id -> Int4, @@ -783,10 +804,10 @@ diesel::table! { } diesel::table! { - person_mention (id) { + person_post_mention (id) { id -> Int4, recipient_id -> Int4, - comment_id -> Int4, + post_id -> Int4, read -> Bool, published -> Timestamptz, } @@ -1091,6 +1112,10 @@ 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 -> local_user (local_user_id)); @@ -1139,10 +1164,12 @@ diesel::joinable!(password_reset_request -> local_user (local_user_id)); diesel::joinable!(person -> instance (instance_id)); diesel::joinable!(person_aggregates -> person (person_id)); diesel::joinable!(person_ban -> person (person_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_mention -> comment (comment_id)); -diesel::joinable!(person_mention -> person (recipient_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)); @@ -1196,6 +1223,7 @@ diesel::allow_tables_to_appear_in_same_query!( federation_blocklist, federation_queue_state, image_details, + inbox_combined, instance, instance_actions, language, @@ -1226,8 +1254,9 @@ diesel::allow_tables_to_appear_in_same_query!( person_actions, person_aggregates, person_ban, + person_comment_mention, person_content_combined, - person_mention, + person_post_mention, person_saved_combined, post, post_actions, diff --git a/crates/db_schema/src/source/combined/inbox.rs b/crates/db_schema/src/source/combined/inbox.rs new file mode 100644 index 000000000..523dd5040 --- /dev/null +++ b/crates/db_schema/src/source/combined/inbox.rs @@ -0,0 +1,33 @@ +use crate::newtypes::{ + CommentReplyId, + InboxCombinedId, + PersonCommentMentionId, + PersonPostMentionId, + PrivateMessageId, +}; +#[cfg(feature = "full")] +use crate::schema::inbox_combined; +use chrono::{DateTime, Utc}; +#[cfg(feature = "full")] +use i_love_jesus::CursorKeysModule; +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))] +/// A combined inbox table. +pub struct InboxCombined { + pub id: InboxCombinedId, + pub published: 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 6beec3921..2555ef5be 100644 --- a/crates/db_schema/src/source/combined/mod.rs +++ b/crates/db_schema/src/source/combined/mod.rs @@ -1,3 +1,4 @@ +pub mod inbox; pub mod modlog; pub mod person_content; pub mod person_saved; diff --git a/crates/db_schema/src/source/mod.rs b/crates/db_schema/src/source/mod.rs index 2f6f9172b..c34be3726 100644 --- a/crates/db_schema/src/source/mod.rs +++ b/crates/db_schema/src/source/mod.rs @@ -34,7 +34,8 @@ pub mod oauth_provider; pub mod password_reset_request; pub mod person; pub mod person_block; -pub mod person_mention; +pub mod person_comment_mention; +pub mod person_post_mention; pub mod post; pub mod post_report; pub mod private_message; diff --git a/crates/db_schema/src/source/person_mention.rs b/crates/db_schema/src/source/person_comment_mention.rs similarity index 63% rename from crates/db_schema/src/source/person_mention.rs rename to crates/db_schema/src/source/person_comment_mention.rs index 9c3005655..bd70af307 100644 --- a/crates/db_schema/src/source/person_mention.rs +++ b/crates/db_schema/src/source/person_comment_mention.rs @@ -1,6 +1,6 @@ -use crate::newtypes::{CommentId, PersonId, PersonMentionId}; +use crate::newtypes::{CommentId, PersonCommentMentionId, PersonId}; #[cfg(feature = "full")] -use crate::schema::person_mention; +use crate::schema::person_comment_mention; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; #[cfg(feature = "full")] @@ -12,12 +12,12 @@ use ts_rs::TS; derive(Queryable, Selectable, Associations, Identifiable, TS) )] #[cfg_attr(feature = "full", diesel(belongs_to(crate::source::comment::Comment)))] -#[cfg_attr(feature = "full", diesel(table_name = person_mention))] +#[cfg_attr(feature = "full", diesel(table_name = person_comment_mention))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", ts(export))] /// A person mention. -pub struct PersonMention { - pub id: PersonMentionId, +pub struct PersonCommentMention { + pub id: PersonCommentMentionId, pub recipient_id: PersonId, pub comment_id: CommentId, pub read: bool, @@ -25,15 +25,15 @@ pub struct PersonMention { } #[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] -#[cfg_attr(feature = "full", diesel(table_name = person_mention))] -pub struct PersonMentionInsertForm { +#[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_mention))] -pub struct PersonMentionUpdateForm { +#[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 new file mode 100644 index 000000000..b1c00febf --- /dev/null +++ b/crates/db_schema/src/source/person_post_mention.rs @@ -0,0 +1,39 @@ +use crate::newtypes::{PersonId, PersonPostMentionId, PostId}; +#[cfg(feature = "full")] +use crate::schema::person_post_mention; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +#[cfg(feature = "full")] +use ts_rs::TS; + +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[cfg_attr( + feature = "full", + derive(Queryable, Selectable, Associations, Identifiable, TS) +)] +#[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 = "full", ts(export))] +/// A person mention. +pub struct PersonPostMention { + pub id: PersonPostMentionId, + pub recipient_id: PersonId, + pub post_id: PostId, + pub read: bool, + pub published: 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_views/src/lib.rs b/crates/db_views/src/lib.rs index 829870c0a..74a1cd5c6 100644 --- a/crates/db_views/src/lib.rs +++ b/crates/db_views/src/lib.rs @@ -24,8 +24,6 @@ pub mod post_view; #[cfg(feature = "full")] pub mod private_message_report_view; #[cfg(feature = "full")] -pub mod private_message_view; -#[cfg(feature = "full")] pub mod registration_application_view; #[cfg(feature = "full")] pub mod report_combined_view; diff --git a/crates/db_views/src/structs.rs b/crates/db_views/src/structs.rs index 325fbabd0..eedaa7980 100644 --- a/crates/db_views/src/structs.rs +++ b/crates/db_views/src/structs.rs @@ -198,17 +198,6 @@ pub struct PostView { pub tags: PostTags, } -#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] -#[cfg_attr(feature = "full", derive(TS, Queryable))] -#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] -#[cfg_attr(feature = "full", ts(export))] -/// A private message view. -pub struct PrivateMessageView { - pub private_message: PrivateMessage, - pub creator: Person, - pub recipient: Person, -} - #[skip_serializing_none] #[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(TS, Queryable))] diff --git a/crates/db_views_actor/Cargo.toml b/crates/db_views_actor/Cargo.toml index 00f8bdcaf..34d64d0b0 100644 --- a/crates/db_views_actor/Cargo.toml +++ b/crates/db_views_actor/Cargo.toml @@ -18,6 +18,7 @@ workspace = true full = [ "lemmy_db_schema/full", "lemmy_utils/full", + "i-love-jesus", "diesel", "diesel-async", "ts-rs", @@ -40,10 +41,10 @@ ts-rs = { workspace = true, optional = true } chrono.workspace = true strum = { workspace = true } lemmy_utils = { workspace = true, optional = true } +i-love-jesus = { workspace = true, optional = true } [dev-dependencies] serial_test = { workspace = true } tokio = { workspace = true } pretty_assertions = { workspace = true } url.workspace = true -lemmy_db_views = { workspace = true, features = ["full"] } diff --git a/crates/db_views_actor/src/comment_reply_view.rs b/crates/db_views_actor/src/comment_reply_view.rs deleted file mode 100644 index 75f8ed4e2..000000000 --- a/crates/db_views_actor/src/comment_reply_view.rs +++ /dev/null @@ -1,379 +0,0 @@ -use crate::structs::CommentReplyView; -use diesel::{ - dsl::{exists, not}, - pg::Pg, - result::Error, - BoolExpressionMethods, - ExpressionMethods, - JoinOnDsl, - NullableExpressionMethods, - QueryDsl, -}; -use diesel_async::RunQueryDsl; -use lemmy_db_schema::{ - aliases::{self, creator_community_actions}, - newtypes::{CommentReplyId, PersonId}, - schema::{ - comment, - comment_actions, - comment_aggregates, - comment_reply, - community, - community_actions, - local_user, - person, - person_actions, - post, - }, - source::{community::CommunityFollower, local_user::LocalUser}, - utils::{ - actions, - actions_alias, - get_conn, - limit_and_offset, - DbConn, - DbPool, - ListFn, - Queries, - ReadFn, - }, - CommentSortType, -}; - -fn queries<'a>() -> Queries< - impl ReadFn<'a, CommentReplyView, (CommentReplyId, Option)>, - impl ListFn<'a, CommentReplyView, CommentReplyQuery>, -> { - let creator_is_admin = exists( - local_user::table.filter( - comment::creator_id - .eq(local_user::person_id) - .and(local_user::admin.eq(true)), - ), - ); - - let all_joins = move |query: comment_reply::BoxedQuery<'a, Pg>, - my_person_id: Option| { - query - .inner_join(comment::table) - .inner_join(person::table.on(comment::creator_id.eq(person::id))) - .inner_join(post::table.on(comment::post_id.eq(post::id))) - .inner_join(community::table.on(post::community_id.eq(community::id))) - .inner_join(aliases::person1) - .inner_join(comment_aggregates::table.on(comment::id.eq(comment_aggregates::comment_id))) - .left_join(actions(comment_actions::table, my_person_id, comment::id)) - .left_join(actions( - community_actions::table, - my_person_id, - post::community_id, - )) - .left_join(actions( - person_actions::table, - my_person_id, - comment::creator_id, - )) - .left_join(actions_alias( - creator_community_actions, - comment::creator_id, - post::community_id, - )) - .select(( - comment_reply::all_columns, - comment::all_columns, - person::all_columns, - post::all_columns, - community::all_columns, - aliases::person1.fields(person::all_columns), - comment_aggregates::all_columns, - creator_community_actions - .field(community_actions::received_ban) - .nullable() - .is_not_null(), - community_actions::received_ban.nullable().is_not_null(), - creator_community_actions - .field(community_actions::became_moderator) - .nullable() - .is_not_null(), - creator_is_admin, - CommunityFollower::select_subscribed_type(), - comment_actions::saved.nullable().is_not_null(), - person_actions::blocked.nullable().is_not_null(), - comment_actions::like_score.nullable(), - )) - }; - - let read = - move |mut conn: DbConn<'a>, - (comment_reply_id, my_person_id): (CommentReplyId, Option)| async move { - all_joins( - comment_reply::table.find(comment_reply_id).into_boxed(), - my_person_id, - ) - .first(&mut conn) - .await - }; - - let list = move |mut conn: DbConn<'a>, o: CommentReplyQuery| async move { - // These filters need to be kept in sync with the filters in - // CommentReplyView::get_unread_replies() - let mut query = all_joins(comment_reply::table.into_boxed(), o.my_person_id); - - if let Some(recipient_id) = o.recipient_id { - query = query.filter(comment_reply::recipient_id.eq(recipient_id)); - } - - if o.unread_only { - query = query.filter(comment_reply::read.eq(false)); - } - - if !o.show_bot_accounts { - query = query.filter(not(person::bot_account)); - }; - - query = match o.sort.unwrap_or(CommentSortType::New) { - CommentSortType::Hot => query.then_order_by(comment_aggregates::hot_rank.desc()), - CommentSortType::Controversial => { - query.then_order_by(comment_aggregates::controversy_rank.desc()) - } - CommentSortType::New => query.then_order_by(comment_reply::published.desc()), - CommentSortType::Old => query.then_order_by(comment_reply::published.asc()), - CommentSortType::Top => query.order_by(comment_aggregates::score.desc()), - }; - - // Don't show replies from blocked persons - query = query.filter(person_actions::blocked.is_null()); - - let (limit, offset) = limit_and_offset(o.page, o.limit)?; - - query - .limit(limit) - .offset(offset) - .load::(&mut conn) - .await - }; - - Queries::new(read, list) -} - -impl CommentReplyView { - pub async fn read( - pool: &mut DbPool<'_>, - comment_reply_id: CommentReplyId, - my_person_id: Option, - ) -> Result { - queries().read(pool, (comment_reply_id, my_person_id)).await - } - - /// Gets the number of unread replies - pub async fn get_unread_replies( - pool: &mut DbPool<'_>, - local_user: &LocalUser, - ) -> Result { - use diesel::dsl::count; - - let conn = &mut get_conn(pool).await?; - - let mut query = comment_reply::table - .inner_join(comment::table) - .left_join(actions( - person_actions::table, - Some(local_user.person_id), - comment::creator_id, - )) - .inner_join(person::table.on(comment::creator_id.eq(person::id))) - .into_boxed(); - - // These filters need to be kept in sync with the filters in queries().list() - if !local_user.show_bot_accounts { - query = query.filter(not(person::bot_account)); - } - - query - // Don't count replies from blocked users - .filter(person_actions::blocked.is_null()) - .filter(comment_reply::recipient_id.eq(local_user.person_id)) - .filter(comment_reply::read.eq(false)) - .filter(comment::deleted.eq(false)) - .filter(comment::removed.eq(false)) - .select(count(comment_reply::id)) - .first::(conn) - .await - } -} - -#[derive(Default, Clone)] -pub struct CommentReplyQuery { - pub my_person_id: Option, - pub recipient_id: Option, - pub sort: Option, - pub unread_only: bool, - pub show_bot_accounts: bool, - pub page: Option, - pub limit: Option, -} - -impl CommentReplyQuery { - pub async fn list(self, pool: &mut DbPool<'_>) -> Result, Error> { - queries().list(pool, self).await - } -} - -#[cfg(test)] -mod tests { - - use crate::{comment_reply_view::CommentReplyQuery, structs::CommentReplyView}; - use lemmy_db_schema::{ - source::{ - comment::{Comment, CommentInsertForm}, - comment_reply::{CommentReply, CommentReplyInsertForm, CommentReplyUpdateForm}, - community::{Community, CommunityInsertForm}, - instance::Instance, - local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm}, - person::{Person, PersonInsertForm, PersonUpdateForm}, - person_block::{PersonBlock, PersonBlockForm}, - post::{Post, PostInsertForm}, - }, - traits::{Blockable, Crud}, - utils::build_db_pool_for_tests, - }; - use lemmy_db_views::structs::LocalUserView; - use lemmy_utils::error::LemmyResult; - use pretty_assertions::assert_eq; - use serial_test::serial; - - #[tokio::test] - #[serial] - async fn test_crud() -> LemmyResult<()> { - let pool = &build_db_pool_for_tests(); - let pool = &mut pool.into(); - - let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?; - - let terry_form = PersonInsertForm::test_form(inserted_instance.id, "terrylake"); - let inserted_terry = Person::create(pool, &terry_form).await?; - - let recipient_form = PersonInsertForm { - local: Some(true), - ..PersonInsertForm::test_form(inserted_instance.id, "terrylakes recipient") - }; - - let inserted_recipient = Person::create(pool, &recipient_form).await?; - let recipient_id = inserted_recipient.id; - - let recipient_local_user = - LocalUser::create(pool, &LocalUserInsertForm::test_form(recipient_id), vec![]).await?; - - let new_community = CommunityInsertForm::new( - inserted_instance.id, - "test community lake".to_string(), - "nada".to_owned(), - "pubkey".to_string(), - ); - let inserted_community = Community::create(pool, &new_community).await?; - - let new_post = PostInsertForm::new( - "A test post".into(), - inserted_terry.id, - inserted_community.id, - ); - let inserted_post = Post::create(pool, &new_post).await?; - - let comment_form = - CommentInsertForm::new(inserted_terry.id, inserted_post.id, "A test comment".into()); - let inserted_comment = Comment::create(pool, &comment_form, None).await?; - - let comment_reply_form = CommentReplyInsertForm { - recipient_id: inserted_recipient.id, - comment_id: inserted_comment.id, - read: None, - }; - - let inserted_reply = CommentReply::create(pool, &comment_reply_form).await?; - - let expected_reply = CommentReply { - id: inserted_reply.id, - recipient_id: inserted_reply.recipient_id, - comment_id: inserted_reply.comment_id, - read: false, - published: inserted_reply.published, - }; - - let read_reply = CommentReply::read(pool, inserted_reply.id).await?; - - let comment_reply_update_form = CommentReplyUpdateForm { read: Some(false) }; - let updated_reply = - CommentReply::update(pool, inserted_reply.id, &comment_reply_update_form).await?; - - // Test to make sure counts and blocks work correctly - let unread_replies = CommentReplyView::get_unread_replies(pool, &recipient_local_user).await?; - - let query = CommentReplyQuery { - recipient_id: Some(recipient_id), - my_person_id: Some(recipient_id), - sort: None, - unread_only: false, - show_bot_accounts: true, - page: None, - limit: None, - }; - let replies = query.clone().list(pool).await?; - assert_eq!(1, unread_replies); - assert_eq!(1, replies.len()); - - // Block the person, and make sure these counts are now empty - let block_form = PersonBlockForm { - person_id: recipient_id, - target_id: inserted_terry.id, - }; - PersonBlock::block(pool, &block_form).await?; - - let unread_replies_after_block = - CommentReplyView::get_unread_replies(pool, &recipient_local_user).await?; - let replies_after_block = query.clone().list(pool).await?; - assert_eq!(0, unread_replies_after_block); - assert_eq!(0, replies_after_block.len()); - - // Unblock user so we can reuse the same person - PersonBlock::unblock(pool, &block_form).await?; - - // Turn Terry into a bot account - let person_update_form = PersonUpdateForm { - bot_account: Some(true), - ..Default::default() - }; - Person::update(pool, inserted_terry.id, &person_update_form).await?; - - let recipient_local_user_update_form = LocalUserUpdateForm { - show_bot_accounts: Some(false), - ..Default::default() - }; - LocalUser::update( - pool, - recipient_local_user.id, - &recipient_local_user_update_form, - ) - .await?; - let recipient_local_user_view = LocalUserView::read(pool, recipient_local_user.id).await?; - - let unread_replies_after_hide_bots = - CommentReplyView::get_unread_replies(pool, &recipient_local_user_view.local_user).await?; - - let mut query_without_bots = query.clone(); - query_without_bots.show_bot_accounts = false; - let replies_after_hide_bots = query_without_bots.list(pool).await?; - assert_eq!(0, unread_replies_after_hide_bots); - assert_eq!(0, replies_after_hide_bots.len()); - - Comment::delete(pool, inserted_comment.id).await?; - Post::delete(pool, inserted_post.id).await?; - Community::delete(pool, inserted_community.id).await?; - Person::delete(pool, inserted_terry.id).await?; - Person::delete(pool, inserted_recipient.id).await?; - Instance::delete(pool, inserted_instance.id).await?; - - assert_eq!(expected_reply, read_reply); - assert_eq!(expected_reply, inserted_reply); - assert_eq!(expected_reply, updated_reply); - Ok(()) - } -} diff --git a/crates/db_views_actor/src/inbox_combined_view.rs b/crates/db_views_actor/src/inbox_combined_view.rs new file mode 100644 index 000000000..5c0405742 --- /dev/null +++ b/crates/db_views_actor/src/inbox_combined_view.rs @@ -0,0 +1,991 @@ +use crate::structs::{ + CommentReplyView, + InboxCombinedPaginationCursor, + InboxCombinedView, + InboxCombinedViewInternal, + PersonCommentMentionView, + PersonPostMentionView, + PrivateMessageView, +}; +use diesel::{ + dsl::not, + result::Error, + BoolExpressionMethods, + ExpressionMethods, + JoinOnDsl, + NullableExpressionMethods, + QueryDsl, + SelectableHelper, +}; +use diesel_async::RunQueryDsl; +use i_love_jesus::PaginatedQueryBuilder; +use lemmy_db_schema::{ + aliases::{self, creator_community_actions}, + newtypes::PersonId, + schema::{ + comment, + comment_actions, + comment_aggregates, + comment_reply, + community, + community_actions, + image_details, + inbox_combined, + instance_actions, + local_user, + person, + person_actions, + person_comment_mention, + person_post_mention, + post, + post_actions, + post_aggregates, + private_message, + }, + source::{ + combined::inbox::{inbox_combined_keys as key, InboxCombined}, + community::CommunityFollower, + }, + utils::{actions, actions_alias, functions::coalesce, get_conn, DbPool}, + InboxDataType, + InternalToCombinedView, +}; +use lemmy_utils::error::LemmyResult; + +impl InboxCombinedViewInternal { + /// Gets the number of unread mentions + pub async fn get_unread_count( + pool: &mut DbPool<'_>, + my_person_id: PersonId, + show_bot_accounts: bool, + ) -> Result { + use diesel::dsl::count; + let conn = &mut get_conn(pool).await?; + + let item_creator = person::id; + 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 item_creator_join = 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 = 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_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 = 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 = inbox_combined::private_message_id + .eq(private_message::id.nullable()) + .and(not(private_message::deleted)); + + let mut query = inbox_combined::table + .left_join(comment_reply::table) + .left_join(person_comment_mention::table) + .left_join(person_post_mention::table) + .left_join(private_message::table.on(private_message_join)) + .left_join(comment::table.on(comment_join)) + .left_join(post::table.on(post_join)) + // The item creator + .inner_join(person::table.on(item_creator_join)) + // The recipient + .inner_join(aliases::person1.on(recipient_join)) + .left_join(actions( + instance_actions::table, + Some(my_person_id), + person::instance_id, + )) + .left_join(actions( + person_actions::table, + Some(my_person_id), + item_creator, + )) + // 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.is_null()) + .filter(instance_actions::blocked.is_null()) + .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 + .select(count(inbox_combined::id)) + .first::(conn) + .await + } +} + +impl InboxCombinedPaginationCursor { + // get cursor for page that starts immediately after the given post + pub fn after_post(view: &InboxCombinedView) -> InboxCombinedPaginationCursor { + let (prefix, id) = match view { + 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), + }; + // hex encoding to prevent ossification + InboxCombinedPaginationCursor(format!("{prefix}{id:x}")) + } + + pub async fn read(&self, pool: &mut DbPool<'_>) -> Result { + let err_msg = || Error::QueryBuilderError("Could not parse pagination token".into()); + let mut query = inbox_combined::table + .select(InboxCombined::as_select()) + .into_boxed(); + let (prefix, id_str) = self.0.split_at_checked(1).ok_or_else(err_msg)?; + let id = i32::from_str_radix(id_str, 16).map_err(|_err| err_msg())?; + 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(err_msg()), + }; + let token = query.first(&mut get_conn(pool).await?).await?; + + Ok(PaginationCursorData(token)) + } +} + +#[derive(Clone)] +pub struct PaginationCursorData(InboxCombined); + +#[derive(Default)] +pub struct InboxCombinedQuery { + pub type_: Option, + pub unread_only: Option, + pub show_bot_accounts: Option, + pub page_after: Option, + pub page_back: Option, +} + +impl InboxCombinedQuery { + pub async fn list( + self, + pool: &mut DbPool<'_>, + my_person_id: PersonId, + ) -> LemmyResult> { + let conn = &mut get_conn(pool).await?; + + let item_creator = person::id; + let recipient_person = aliases::person1.field(person::id); + + let item_creator_join = 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 = 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_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 = 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 = inbox_combined::private_message_id + .eq(private_message::id.nullable()) + .and(not(private_message::deleted)); + + let community_join = post::community_id.eq(community::id); + + let mut query = inbox_combined::table + .left_join(comment_reply::table) + .left_join(person_comment_mention::table) + .left_join(person_post_mention::table) + .left_join(private_message::table.on(private_message_join)) + .left_join(comment::table.on(comment_join)) + .left_join(post::table.on(post_join)) + .left_join(community::table.on(community_join)) + // The item creator + .inner_join(person::table.on(item_creator_join)) + // The recipient + .inner_join(aliases::person1.on(recipient_join)) + .left_join(actions_alias( + creator_community_actions, + item_creator, + post::community_id, + )) + .left_join( + local_user::table.on( + item_creator + .eq(local_user::person_id) + .and(local_user::admin.eq(true)), + ), + ) + .left_join(actions( + community_actions::table, + Some(my_person_id), + post::community_id, + )) + .left_join(actions( + instance_actions::table, + Some(my_person_id), + person::instance_id, + )) + .left_join(actions(post_actions::table, Some(my_person_id), post::id)) + .left_join(actions( + person_actions::table, + Some(my_person_id), + item_creator, + )) + .left_join(post_aggregates::table.on(post::id.eq(post_aggregates::post_id))) + .left_join(comment_aggregates::table.on(comment::id.eq(comment_aggregates::comment_id))) + .left_join(actions( + comment_actions::table, + Some(my_person_id), + comment::id, + )) + .left_join(image_details::table.on(post::thumbnail_url.eq(image_details::link.nullable()))) + .select(( + // Specific + comment_reply::all_columns.nullable(), + person_comment_mention::all_columns.nullable(), + person_post_mention::all_columns.nullable(), + post_aggregates::all_columns.nullable(), + coalesce( + post_aggregates::comments.nullable() - post_actions::read_comments_amount.nullable(), + post_aggregates::comments, + ) + .nullable(), + post_actions::saved.nullable().is_not_null(), + post_actions::read.nullable().is_not_null(), + post_actions::hidden.nullable().is_not_null(), + post_actions::like_score.nullable(), + image_details::all_columns.nullable(), + private_message::all_columns.nullable(), + // Shared + post::all_columns.nullable(), + community::all_columns.nullable(), + comment::all_columns.nullable(), + comment_aggregates::all_columns.nullable(), + comment_actions::saved.nullable().is_not_null(), + comment_actions::like_score.nullable(), + CommunityFollower::select_subscribed_type(), + person::all_columns, + aliases::person1.fields(person::all_columns), + local_user::admin.nullable().is_not_null(), + creator_community_actions + .field(community_actions::became_moderator) + .nullable() + .is_not_null(), + creator_community_actions + .field(community_actions::received_ban) + .nullable() + .is_not_null(), + person_actions::blocked.nullable().is_not_null(), + community_actions::received_ban.nullable().is_not_null(), + )) + .into_boxed(); + + // 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.is_null()) + .filter(instance_actions::blocked.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()) + } + } + } + + let mut query = PaginatedQueryBuilder::new(query); + + let page_after = self.page_after.map(|c| c.0); + + if self.page_back.unwrap_or_default() { + query = query.before(page_after).limit_and_offset_from_end(); + } else { + query = query.after(page_after); + } + + // Sorting by published + query = query + .then_desc(key::published) + // Tie breaker + .then_desc(key::id); + + let res = 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(counts), Some(post), Some(community)) = ( + v.comment_reply, + v.comment.clone(), + v.comment_counts.clone(), + v.post.clone(), + v.community.clone(), + ) { + Some(InboxCombinedView::CommentReply(CommentReplyView { + comment_reply, + comment, + counts, + recipient: v.item_recipient, + post, + community, + creator: v.item_creator, + creator_banned_from_community: v.item_creator_banned_from_community, + creator_is_moderator: v.item_creator_is_moderator, + creator_is_admin: v.item_creator_is_admin, + creator_blocked: v.item_creator_blocked, + subscribed: v.subscribed, + saved: v.comment_saved, + my_vote: v.my_comment_vote, + banned_from_community: v.banned_from_community, + })) + } else if let ( + Some(person_comment_mention), + Some(comment), + Some(counts), + Some(post), + Some(community), + ) = ( + v.person_comment_mention, + v.comment, + v.comment_counts, + v.post.clone(), + v.community.clone(), + ) { + Some(InboxCombinedView::CommentMention( + PersonCommentMentionView { + person_comment_mention, + comment, + counts, + recipient: v.item_recipient, + post, + community, + creator: v.item_creator, + creator_banned_from_community: v.item_creator_banned_from_community, + creator_is_moderator: v.item_creator_is_moderator, + creator_is_admin: v.item_creator_is_admin, + creator_blocked: v.item_creator_blocked, + subscribed: v.subscribed, + saved: v.comment_saved, + my_vote: v.my_comment_vote, + banned_from_community: v.banned_from_community, + }, + )) + } else if let ( + Some(person_post_mention), + Some(post), + Some(counts), + Some(unread_comments), + Some(community), + ) = ( + v.person_post_mention, + v.post, + v.post_counts, + v.post_unread_comments, + v.community, + ) { + Some(InboxCombinedView::PostMention(PersonPostMentionView { + person_post_mention, + counts, + post, + community, + recipient: v.item_recipient, + unread_comments, + creator: v.item_creator, + creator_banned_from_community: v.item_creator_banned_from_community, + creator_is_moderator: v.item_creator_is_moderator, + creator_is_admin: v.item_creator_is_admin, + creator_blocked: v.item_creator_blocked, + subscribed: v.subscribed, + saved: v.post_saved, + read: v.post_read, + hidden: v.post_hidden, + my_vote: v.my_post_vote, + image_details: v.image_details, + banned_from_community: v.banned_from_community, + })) + } 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::{ + inbox_combined_view::InboxCombinedQuery, + structs::{InboxCombinedView, InboxCombinedViewInternal, PrivateMessageView}, + }; + use lemmy_db_schema::{ + assert_length, + source::{ + comment::{Comment, CommentInsertForm}, + comment_reply::{CommentReply, CommentReplyInsertForm, CommentReplyUpdateForm}, + community::{Community, CommunityInsertForm}, + instance::Instance, + instance_block::{InstanceBlock, InstanceBlockForm}, + person::{Person, PersonInsertForm, PersonUpdateForm}, + person_block::{PersonBlock, PersonBlockForm}, + 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_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, true).await?; + assert_eq!(1, timmy_unread_replies); + + let timmy_inbox = InboxCombinedQuery::default() + .list(pool, data.timmy.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, true).await?; + assert_eq!(0, timmy_unread_replies); + + let timmy_inbox_unread = InboxCombinedQuery { + unread_only: Some(true), + ..Default::default() + } + .list(pool, data.timmy.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, true).await?; + assert_eq!(2, sara_unread_mentions); + + let sara_inbox = InboxCombinedQuery::default() + .list(pool, data.sara.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 { + person_id: data.sara.id, + target_id: data.timmy.id, + }; + PersonBlock::block(pool, &sara_blocks_timmy_form).await?; + + let sara_unread_mentions_after_block = + InboxCombinedViewInternal::get_unread_count(pool, data.sara.id, true).await?; + assert_eq!(1, sara_unread_mentions_after_block); + + let sara_inbox_after_block = InboxCombinedQuery::default() + .list(pool, data.sara.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 + PersonBlock::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) + .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, false).await?; + assert_eq!(1, sara_unread_mentions_after_hide_bots); + + let sara_inbox_after_hide_bots = InboxCombinedQuery::default() + .list(pool, data.sara.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, false).await?; + assert_eq!(0, sara_unread_mentions); + + let sara_inbox_unread = InboxCombinedQuery { + unread_only: Some(true), + ..Default::default() + } + .list(pool, data.sara.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) + .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, 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) + .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 { + person_id: data.timmy.id, + target_id: data.sara.id, + }; + + let inserted_block = PersonBlock::block(pool, &timmy_blocks_sara_form).await?; + + let expected_block = PersonBlock { + person_id: data.timmy.id, + target_id: data.sara.id, + published: inserted_block.published, + }; + assert_eq!(expected_block, inserted_block); + + let timmy_messages = map_to_pm( + &InboxCombinedQuery { + unread_only: Some(true), + ..Default::default() + } + .list(pool, data.timmy.id) + .await?, + ); + + assert_length!(1, &timmy_messages); + + let timmy_unread = + InboxCombinedViewInternal::get_unread_count(pool, data.timmy.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 { + person_id: data.timmy.id, + instance_id: data.sara.instance_id, + }; + + let inserted_instance_block = InstanceBlock::block(pool, &timmy_blocks_instance_form).await?; + + let expected_instance_block = InstanceBlock { + person_id: data.timmy.id, + instance_id: data.sara.instance_id, + published: inserted_instance_block.published, + }; + assert_eq!(expected_instance_block, inserted_instance_block); + + let timmy_messages = map_to_pm( + &InboxCombinedQuery { + unread_only: Some(true), + ..Default::default() + } + .list(pool, data.timmy.id) + .await?, + ); + + assert_length!(0, &timmy_messages); + + let timmy_unread = + InboxCombinedViewInternal::get_unread_count(pool, data.timmy.id, false).await?; + assert_eq!(0, timmy_unread); + + cleanup(data, pool).await?; + + Ok(()) + } +} diff --git a/crates/db_views_actor/src/lib.rs b/crates/db_views_actor/src/lib.rs index 2ec9652e3..3f3991734 100644 --- a/crates/db_views_actor/src/lib.rs +++ b/crates/db_views_actor/src/lib.rs @@ -1,6 +1,4 @@ #[cfg(feature = "full")] -pub mod comment_reply_view; -#[cfg(feature = "full")] pub mod community_follower_view; #[cfg(feature = "full")] pub mod community_moderator_view; @@ -9,7 +7,9 @@ pub mod community_person_ban_view; #[cfg(feature = "full")] pub mod community_view; #[cfg(feature = "full")] -pub mod person_mention_view; +pub mod inbox_combined_view; #[cfg(feature = "full")] pub mod person_view; +#[cfg(feature = "full")] +pub mod private_message_view; pub mod structs; diff --git a/crates/db_views_actor/src/person_mention_view.rs b/crates/db_views_actor/src/person_mention_view.rs deleted file mode 100644 index b3d6235d4..000000000 --- a/crates/db_views_actor/src/person_mention_view.rs +++ /dev/null @@ -1,383 +0,0 @@ -use crate::structs::PersonMentionView; -use diesel::{ - dsl::{exists, not}, - pg::Pg, - result::Error, - BoolExpressionMethods, - ExpressionMethods, - JoinOnDsl, - NullableExpressionMethods, - QueryDsl, -}; -use diesel_async::RunQueryDsl; -use lemmy_db_schema::{ - aliases::{self, creator_community_actions}, - newtypes::{PersonId, PersonMentionId}, - schema::{ - comment, - comment_actions, - comment_aggregates, - community, - community_actions, - local_user, - person, - person_actions, - person_mention, - post, - }, - source::{community::CommunityFollower, local_user::LocalUser}, - utils::{ - actions, - actions_alias, - get_conn, - limit_and_offset, - DbConn, - DbPool, - ListFn, - Queries, - ReadFn, - }, - CommentSortType, -}; - -fn queries<'a>() -> Queries< - impl ReadFn<'a, PersonMentionView, (PersonMentionId, Option)>, - impl ListFn<'a, PersonMentionView, PersonMentionQuery>, -> { - let creator_is_admin = exists( - local_user::table.filter( - comment::creator_id - .eq(local_user::person_id) - .and(local_user::admin.eq(true)), - ), - ); - - let all_joins = move |query: person_mention::BoxedQuery<'a, Pg>, - my_person_id: Option| { - query - .inner_join(comment::table) - .inner_join(person::table.on(comment::creator_id.eq(person::id))) - .inner_join(post::table.on(comment::post_id.eq(post::id))) - .inner_join(community::table.on(post::community_id.eq(community::id))) - .inner_join(aliases::person1) - .inner_join(comment_aggregates::table.on(comment::id.eq(comment_aggregates::comment_id))) - .left_join(actions( - community_actions::table, - my_person_id, - post::community_id, - )) - .left_join(actions(comment_actions::table, my_person_id, comment::id)) - .left_join(actions( - person_actions::table, - my_person_id, - comment::creator_id, - )) - .left_join(actions_alias( - creator_community_actions, - comment::creator_id, - post::community_id, - )) - .select(( - person_mention::all_columns, - comment::all_columns, - person::all_columns, - post::all_columns, - community::all_columns, - aliases::person1.fields(person::all_columns), - comment_aggregates::all_columns, - creator_community_actions - .field(community_actions::received_ban) - .nullable() - .is_not_null(), - community_actions::received_ban.nullable().is_not_null(), - creator_community_actions - .field(community_actions::became_moderator) - .nullable() - .is_not_null(), - creator_is_admin, - CommunityFollower::select_subscribed_type(), - comment_actions::saved.nullable().is_not_null(), - person_actions::blocked.nullable().is_not_null(), - comment_actions::like_score.nullable(), - )) - }; - - let read = - move |mut conn: DbConn<'a>, - (person_mention_id, my_person_id): (PersonMentionId, Option)| async move { - all_joins( - person_mention::table.find(person_mention_id).into_boxed(), - my_person_id, - ) - .first(&mut conn) - .await - }; - - let list = move |mut conn: DbConn<'a>, o: PersonMentionQuery| async move { - // These filters need to be kept in sync with the filters in - // PersonMentionView::get_unread_mentions() - let mut query = all_joins(person_mention::table.into_boxed(), o.my_person_id); - - if let Some(recipient_id) = o.recipient_id { - query = query.filter(person_mention::recipient_id.eq(recipient_id)); - } - - if o.unread_only { - query = query.filter(person_mention::read.eq(false)); - } - - if !o.show_bot_accounts { - query = query.filter(not(person::bot_account)); - }; - - query = match o.sort.unwrap_or(CommentSortType::Hot) { - CommentSortType::Hot => query.then_order_by(comment_aggregates::hot_rank.desc()), - CommentSortType::Controversial => { - query.then_order_by(comment_aggregates::controversy_rank.desc()) - } - CommentSortType::New => query.then_order_by(comment::published.desc()), - CommentSortType::Old => query.then_order_by(comment::published.asc()), - CommentSortType::Top => query.order_by(comment_aggregates::score.desc()), - }; - - // Don't show mentions from blocked persons - query = query.filter(person_actions::blocked.is_null()); - - let (limit, offset) = limit_and_offset(o.page, o.limit)?; - - query - .limit(limit) - .offset(offset) - .load::(&mut conn) - .await - }; - - Queries::new(read, list) -} - -impl PersonMentionView { - pub async fn read( - pool: &mut DbPool<'_>, - person_mention_id: PersonMentionId, - my_person_id: Option, - ) -> Result { - queries() - .read(pool, (person_mention_id, my_person_id)) - .await - } - - /// Gets the number of unread mentions - pub async fn get_unread_mentions( - pool: &mut DbPool<'_>, - local_user: &LocalUser, - ) -> Result { - use diesel::dsl::count; - let conn = &mut get_conn(pool).await?; - - let mut query = person_mention::table - .inner_join(comment::table) - .left_join(actions( - person_actions::table, - Some(local_user.person_id), - comment::creator_id, - )) - .inner_join(person::table.on(comment::creator_id.eq(person::id))) - .into_boxed(); - - // These filters need to be kept in sync with the filters in queries().list() - if !local_user.show_bot_accounts { - query = query.filter(not(person::bot_account)); - } - - query - // Don't count replies from blocked users - .filter(person_actions::blocked.is_null()) - .filter(person_mention::recipient_id.eq(local_user.person_id)) - .filter(person_mention::read.eq(false)) - .filter(comment::deleted.eq(false)) - .filter(comment::removed.eq(false)) - .select(count(person_mention::id)) - .first::(conn) - .await - } -} - -#[derive(Default, Clone)] -pub struct PersonMentionQuery { - pub my_person_id: Option, - pub recipient_id: Option, - pub sort: Option, - pub unread_only: bool, - pub show_bot_accounts: bool, - pub page: Option, - pub limit: Option, -} - -impl PersonMentionQuery { - pub async fn list(self, pool: &mut DbPool<'_>) -> Result, Error> { - queries().list(pool, self).await - } -} - -#[cfg(test)] -mod tests { - - use crate::{person_mention_view::PersonMentionQuery, structs::PersonMentionView}; - use lemmy_db_schema::{ - source::{ - comment::{Comment, CommentInsertForm}, - community::{Community, CommunityInsertForm}, - instance::Instance, - local_user::{LocalUser, LocalUserInsertForm, LocalUserUpdateForm}, - person::{Person, PersonInsertForm, PersonUpdateForm}, - person_block::{PersonBlock, PersonBlockForm}, - person_mention::{PersonMention, PersonMentionInsertForm, PersonMentionUpdateForm}, - post::{Post, PostInsertForm}, - }, - traits::{Blockable, Crud}, - utils::build_db_pool_for_tests, - }; - use lemmy_db_views::structs::LocalUserView; - use lemmy_utils::error::LemmyResult; - use pretty_assertions::assert_eq; - use serial_test::serial; - - #[tokio::test] - #[serial] - async fn test_crud() -> LemmyResult<()> { - let pool = &build_db_pool_for_tests(); - let pool = &mut pool.into(); - - let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?; - - let new_person = PersonInsertForm::test_form(inserted_instance.id, "terrylake"); - - let inserted_person = Person::create(pool, &new_person).await?; - - let recipient_form = PersonInsertForm::test_form(inserted_instance.id, "terrylakes recipient"); - - let inserted_recipient = Person::create(pool, &recipient_form).await?; - let recipient_id = inserted_recipient.id; - - let recipient_local_user = - LocalUser::create(pool, &LocalUserInsertForm::test_form(recipient_id), vec![]).await?; - - let new_community = CommunityInsertForm::new( - inserted_instance.id, - "test community lake".to_string(), - "nada".to_owned(), - "pubkey".to_string(), - ); - let inserted_community = Community::create(pool, &new_community).await?; - - let new_post = PostInsertForm::new( - "A test post".into(), - inserted_person.id, - inserted_community.id, - ); - let inserted_post = Post::create(pool, &new_post).await?; - - let comment_form = CommentInsertForm::new( - inserted_person.id, - inserted_post.id, - "A test comment".into(), - ); - let inserted_comment = Comment::create(pool, &comment_form, None).await?; - - let person_mention_form = PersonMentionInsertForm { - recipient_id: inserted_recipient.id, - comment_id: inserted_comment.id, - read: None, - }; - - let inserted_mention = PersonMention::create(pool, &person_mention_form).await?; - - let expected_mention = PersonMention { - id: inserted_mention.id, - recipient_id: inserted_mention.recipient_id, - comment_id: inserted_mention.comment_id, - read: false, - published: inserted_mention.published, - }; - - let read_mention = PersonMention::read(pool, inserted_mention.id).await?; - - let person_mention_update_form = PersonMentionUpdateForm { read: Some(false) }; - let updated_mention = - PersonMention::update(pool, inserted_mention.id, &person_mention_update_form).await?; - - // Test to make sure counts and blocks work correctly - let unread_mentions = - PersonMentionView::get_unread_mentions(pool, &recipient_local_user).await?; - - let query = PersonMentionQuery { - recipient_id: Some(recipient_id), - my_person_id: Some(recipient_id), - sort: None, - unread_only: false, - show_bot_accounts: true, - page: None, - limit: None, - }; - let mentions = query.clone().list(pool).await?; - assert_eq!(1, unread_mentions); - assert_eq!(1, mentions.len()); - - // Block the person, and make sure these counts are now empty - let block_form = PersonBlockForm { - person_id: recipient_id, - target_id: inserted_person.id, - }; - PersonBlock::block(pool, &block_form).await?; - - let unread_mentions_after_block = - PersonMentionView::get_unread_mentions(pool, &recipient_local_user).await?; - let mentions_after_block = query.clone().list(pool).await?; - assert_eq!(0, unread_mentions_after_block); - assert_eq!(0, mentions_after_block.len()); - - // Unblock user so we can reuse the same person - PersonBlock::unblock(pool, &block_form).await?; - - // Turn Terry into a bot account - let person_update_form = PersonUpdateForm { - bot_account: Some(true), - ..Default::default() - }; - Person::update(pool, inserted_person.id, &person_update_form).await?; - - let recipient_local_user_update_form = LocalUserUpdateForm { - show_bot_accounts: Some(false), - ..Default::default() - }; - LocalUser::update( - pool, - recipient_local_user.id, - &recipient_local_user_update_form, - ) - .await?; - let recipient_local_user_view = LocalUserView::read(pool, recipient_local_user.id).await?; - - let unread_mentions_after_hide_bots = - PersonMentionView::get_unread_mentions(pool, &recipient_local_user_view.local_user).await?; - - let mut query_without_bots = query.clone(); - query_without_bots.show_bot_accounts = false; - let replies_after_hide_bots = query_without_bots.list(pool).await?; - assert_eq!(0, unread_mentions_after_hide_bots); - assert_eq!(0, replies_after_hide_bots.len()); - - Comment::delete(pool, inserted_comment.id).await?; - Post::delete(pool, inserted_post.id).await?; - Community::delete(pool, inserted_community.id).await?; - Person::delete(pool, inserted_person.id).await?; - Person::delete(pool, inserted_recipient.id).await?; - Instance::delete(pool, inserted_instance.id).await?; - - assert_eq!(expected_mention, read_mention); - assert_eq!(expected_mention, inserted_mention); - assert_eq!(expected_mention, updated_mention); - - Ok(()) - } -} diff --git a/crates/db_views_actor/src/private_message_view.rs b/crates/db_views_actor/src/private_message_view.rs new file mode 100644 index 000000000..2345e7466 --- /dev/null +++ b/crates/db_views_actor/src/private_message_view.rs @@ -0,0 +1,42 @@ +use crate::structs::PrivateMessageView; +use diesel::{result::Error, ExpressionMethods, JoinOnDsl, QueryDsl}; +use diesel_async::RunQueryDsl; +use lemmy_db_schema::{ + aliases, + newtypes::PrivateMessageId, + schema::{instance_actions, person, person_actions, private_message}, + utils::{actions, get_conn, DbPool}, +}; + +impl PrivateMessageView { + pub async fn read( + pool: &mut DbPool<'_>, + private_message_id: PrivateMessageId, + ) -> Result { + let conn = &mut get_conn(pool).await?; + + private_message::table + .find(private_message_id) + .inner_join(person::table.on(private_message::creator_id.eq(person::id))) + .inner_join( + aliases::person1.on(private_message::recipient_id.eq(aliases::person1.field(person::id))), + ) + .left_join(actions( + person_actions::table, + Some(aliases::person1.field(person::id)), + private_message::creator_id, + )) + .left_join(actions( + instance_actions::table, + Some(aliases::person1.field(person::id)), + person::instance_id, + )) + .select(( + private_message::all_columns, + person::all_columns, + aliases::person1.fields(person::all_columns), + )) + .first(conn) + .await + } +} diff --git a/crates/db_views_actor/src/structs.rs b/crates/db_views_actor/src/structs.rs index 6b609a753..b1f75c86d 100644 --- a/crates/db_views_actor/src/structs.rs +++ b/crates/db_views_actor/src/structs.rs @@ -1,14 +1,17 @@ #[cfg(feature = "full")] use diesel::Queryable; use lemmy_db_schema::{ - aggregates::structs::{CommentAggregates, CommunityAggregates, PersonAggregates}, + aggregates::structs::{CommentAggregates, CommunityAggregates, PersonAggregates, PostAggregates}, source::{ comment::Comment, comment_reply::CommentReply, community::Community, + images::ImageDetails, person::Person, - person_mention::PersonMention, + person_comment_mention::PersonCommentMention, + person_post_mention::PersonPostMention, post::Post, + private_message::PrivateMessage, }, SubscribedType, }; @@ -93,9 +96,9 @@ pub enum CommunitySortType { #[cfg_attr(feature = "full", derive(TS, Queryable))] #[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] #[cfg_attr(feature = "full", ts(export))] -/// A person mention view. -pub struct PersonMentionView { - pub person_mention: PersonMention, +/// A person comment mention view. +pub struct PersonCommentMentionView { + pub person_comment_mention: PersonCommentMention, pub comment: Comment, pub creator: Person, pub post: Post, @@ -113,6 +116,35 @@ pub struct PersonMentionView { pub my_vote: Option, } +#[skip_serializing_none] +#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "full", derive(TS, Queryable))] +#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] +#[cfg_attr(feature = "full", ts(export))] +/// A person post mention view. +pub struct PersonPostMentionView { + pub person_post_mention: PersonPostMention, + pub post: Post, + pub creator: Person, + pub community: Community, + #[cfg_attr(feature = "full", ts(optional))] + pub image_details: Option, + pub recipient: Person, + pub counts: PostAggregates, + pub creator_banned_from_community: bool, + pub banned_from_community: bool, + pub creator_is_moderator: bool, + pub creator_is_admin: bool, + pub subscribed: SubscribedType, + pub saved: bool, + pub read: bool, + pub hidden: bool, + pub creator_blocked: bool, + #[cfg_attr(feature = "full", ts(optional))] + pub my_vote: Option, + pub unread_comments: i64, +} + #[skip_serializing_none] #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] #[cfg_attr(feature = "full", derive(TS, Queryable))] @@ -159,3 +191,69 @@ pub struct PendingFollow { pub is_new_instance: bool, pub subscribed: SubscribedType, } + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "full", derive(TS, Queryable))] +#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] +#[cfg_attr(feature = "full", ts(export))] +/// A private message view. +pub struct PrivateMessageView { + pub private_message: PrivateMessage, + pub creator: Person, + pub recipient: Person, +} + +/// like PaginationCursor but for the report_combined table +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +pub struct InboxCombinedPaginationCursor(pub String); + +#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "full", derive(Queryable))] +#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] +/// A combined inbox view +pub struct InboxCombinedViewInternal { + // Comment reply + pub comment_reply: Option, + // Person comment mention + pub person_comment_mention: Option, + // Person post mention + pub person_post_mention: Option, + pub post_counts: Option, + pub post_unread_comments: Option, + pub post_saved: bool, + pub post_read: bool, + pub post_hidden: bool, + pub my_post_vote: Option, + pub image_details: Option, + // Private message + pub private_message: Option, + // Shared + pub post: Option, + pub community: Option, + pub comment: Option, + pub comment_counts: Option, + pub comment_saved: bool, + pub my_comment_vote: Option, + pub subscribed: SubscribedType, + pub item_creator: Person, + pub item_recipient: Person, + pub item_creator_is_admin: bool, + pub item_creator_is_moderator: bool, + pub item_creator_banned_from_community: bool, + pub item_creator_blocked: bool, + pub banned_from_community: bool, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", 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), +} diff --git a/crates/routes/src/feeds.rs b/crates/routes/src/feeds.rs index cd1ca3e98..f723572dd 100644 --- a/crates/routes/src/feeds.rs +++ b/crates/routes/src/feeds.rs @@ -6,7 +6,6 @@ use lemmy_api_common::{context::LemmyContext, utils::check_private_instance}; use lemmy_db_schema::{ source::{community::Community, person::Person}, traits::ApubActor, - CommentSortType, CommunityVisibility, ListingType, PostSortType, @@ -15,11 +14,7 @@ use lemmy_db_views::{ post_view::PostQuery, structs::{PostView, SiteView}, }; -use lemmy_db_views_actor::{ - comment_reply_view::CommentReplyQuery, - person_mention_view::PersonMentionQuery, - structs::{CommentReplyView, PersonMentionView}, -}; +use lemmy_db_views_actor::{inbox_combined_view::InboxCombinedQuery, structs::InboxCombinedView}; use lemmy_utils::{ cache_header::cache_1hour, error::{LemmyError, LemmyErrorType, LemmyResult}, @@ -360,37 +355,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_user = local_user_view_from_jwt(jwt, context).await?; - let person_id = local_user.local_user.person_id; - let show_bot_accounts = local_user.local_user.show_bot_accounts; - - let sort = CommentSortType::New; + 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 replies = CommentReplyQuery { - recipient_id: (Some(person_id)), - my_person_id: (Some(person_id)), - show_bot_accounts: (show_bot_accounts), - sort: (Some(sort)), - limit: (Some(RSS_FETCH_LIMIT)), + let inbox = InboxCombinedQuery { + show_bot_accounts, ..Default::default() } - .list(&mut context.pool()) - .await?; - - let mentions = PersonMentionQuery { - recipient_id: (Some(person_id)), - my_person_id: (Some(person_id)), - show_bot_accounts: (show_bot_accounts), - sort: (Some(sort)), - limit: (Some(RSS_FETCH_LIMIT)), - ..Default::default() - } - .list(&mut context.pool()) + .list(&mut context.pool(), my_person_id) .await?; let protocol_and_hostname = context.settings().get_protocol_and_hostname(); - let items = create_reply_and_mention_items(replies, mentions, &protocol_and_hostname)?; + let items = create_reply_and_mention_items(inbox, &protocol_and_hostname)?; let mut channel = Channel { namespaces: RSS_NAMESPACE.clone(), @@ -409,39 +387,55 @@ async fn get_feed_inbox(context: &LemmyContext, jwt: &str) -> LemmyResult, - mentions: Vec, + inbox: Vec, protocol_and_hostname: &str, ) -> LemmyResult> { - let mut reply_items: Vec = replies + let reply_items: Vec = inbox .iter() - .map(|r| { - let reply_url = format!("{}/comment/{}", protocol_and_hostname, r.comment.id); - build_item( - &r.creator.name, - &r.comment.published, - &reply_url, - &r.comment.content, - protocol_and_hostname, - ) + .map(|r| match r { + InboxCombinedView::CommentReply(v) => { + let reply_url = format!("{}/comment/{}", protocol_and_hostname, v.comment.id); + build_item( + &v.creator.name, + &v.comment.published, + &reply_url, + &v.comment.content, + protocol_and_hostname, + ) + } + InboxCombinedView::CommentMention(v) => { + let mention_url = format!("{}/comment/{}", protocol_and_hostname, v.comment.id); + build_item( + &v.creator.name, + &v.comment.published, + &mention_url, + &v.comment.content, + protocol_and_hostname, + ) + } + InboxCombinedView::PostMention(v) => { + let mention_url = format!("{}/post/{}", protocol_and_hostname, v.post.id); + build_item( + &v.creator.name, + &v.post.published, + &mention_url, + &v.post.body.clone().unwrap_or_default(), + protocol_and_hostname, + ) + } + InboxCombinedView::PrivateMessage(v) => { + let inbox_url = format!("{}/inbox", protocol_and_hostname); + build_item( + &v.creator.name, + &v.private_message.published, + &inbox_url, + &v.private_message.content, + protocol_and_hostname, + ) + } }) .collect::>>()?; - let mut mention_items: Vec = mentions - .iter() - .map(|m| { - let mention_url = format!("{}/comment/{}", protocol_and_hostname, m.comment.id); - build_item( - &m.creator.name, - &m.comment.published, - &mention_url, - &m.comment.content, - protocol_and_hostname, - ) - }) - .collect::>>()?; - - reply_items.append(&mut mention_items); Ok(reply_items) } diff --git a/crates/utils/src/error.rs b/crates/utils/src/error.rs index 1b3b36c27..d9b02cf5a 100644 --- a/crates/utils/src/error.rs +++ b/crates/utils/src/error.rs @@ -107,7 +107,7 @@ pub enum LemmyErrorType { CouldntHidePost, CouldntUpdateCommunity, CouldntUpdateReplies, - CouldntUpdatePersonMentions, + CouldntUpdatePersonCommentMentions, CouldntCreatePost, CouldntCreatePrivateMessage, CouldntUpdatePrivate, diff --git a/migrations/2024-12-05-233704_add_person_content_combined_table/up.sql b/migrations/2024-12-05-233704_add_person_content_combined_table/up.sql index 63a4d1f39..805d2ca94 100644 --- a/migrations/2024-12-05-233704_add_person_content_combined_table/up.sql +++ b/migrations/2024-12-05-233704_add_person_content_combined_table/up.sql @@ -33,12 +33,8 @@ CREATE TABLE person_saved_combined ( id serial PRIMARY KEY, saved timestamptz NOT NULL, person_id int NOT NULL REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE, - post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE, - comment_id int REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE, - -- Unique constraints are different here, check for person AND item. - -- Otherwise you won't be able to add multiple posts - UNIQUE (person_id, post_id), - UNIQUE (person_id, comment_id), + post_id int UNIQUE REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE, + comment_id int UNIQUE REFERENCES COMMENT ON UPDATE CASCADE ON DELETE CASCADE, -- Make sure only one of the columns is not null CHECK (num_nonnulls (post_id, comment_id) = 1) ); diff --git a/migrations/2024-12-10-193418_add_inbox_combined_table/down.sql b/migrations/2024-12-10-193418_add_inbox_combined_table/down.sql new file mode 100644 index 000000000..d701d7472 --- /dev/null +++ b/migrations/2024-12-10-193418_add_inbox_combined_table/down.sql @@ -0,0 +1,6 @@ +-- Rename the person_mention table to person_comment_mention +ALTER TABLE person_comment_mention RENAME TO person_mention; + +-- Drop the new tables +DROP TABLE person_post_mention, inbox_combined; + diff --git a/migrations/2024-12-10-193418_add_inbox_combined_table/up.sql b/migrations/2024-12-10-193418_add_inbox_combined_table/up.sql new file mode 100644 index 000000000..921d19004 --- /dev/null +++ b/migrations/2024-12-10-193418_add_inbox_combined_table/up.sql @@ -0,0 +1,69 @@ +-- Creates combined tables for +-- Inbox: (replies, comment mentions, post mentions, and private_messages) +-- Also add post mentions, since these didn't exist before. +-- Rename the person_mention table to person_comment_mention +ALTER TABLE person_mention RENAME TO person_comment_mention; + +-- Create the new post_mention table +CREATE TABLE person_post_mention ( + id serial PRIMARY KEY, + recipient_id int REFERENCES person ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + post_id int REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + read boolean DEFAULT FALSE NOT NULL, + published timestamptz NOT NULL DEFAULT now(), + UNIQUE (recipient_id, post_id) +); + +CREATE TABLE inbox_combined ( + id serial PRIMARY KEY, + published timestamptz NOT NULL, + comment_reply_id int UNIQUE REFERENCES comment_reply ON UPDATE CASCADE ON DELETE CASCADE, + person_comment_mention_id int UNIQUE REFERENCES person_comment_mention ON UPDATE CASCADE ON DELETE CASCADE, + person_post_mention_id int UNIQUE REFERENCES person_post_mention ON UPDATE CASCADE ON DELETE CASCADE, + private_message_id int UNIQUE REFERENCES private_message ON UPDATE CASCADE ON DELETE CASCADE, + -- Make sure only one of the columns is not null + CHECK (num_nonnulls (comment_reply_id, person_comment_mention_id, person_post_mention_id, private_message_id) = 1) +); + +CREATE INDEX idx_inbox_combined_published ON inbox_combined (published DESC, id DESC); + +CREATE INDEX idx_inbox_combined_published_asc ON inbox_combined (reverse_timestamp_sort (published) DESC, id DESC); + +-- Updating the history +INSERT INTO inbox_combined (published, comment_reply_id, person_comment_mention_id, person_post_mention_id, private_message_id) +SELECT + published, + id, + NULL::int, + NULL::int, + NULL::int +FROM + comment_reply +UNION ALL +SELECT + published, + NULL::int, + id, + NULL::int, + NULL::int +FROM + person_comment_mention +UNION ALL +SELECT + published, + NULL::int, + NULL::int, + id, + NULL::int +FROM + person_post_mention +UNION ALL +SELECT + published, + NULL::int, + NULL::int, + NULL::int, + id +FROM + private_message; + diff --git a/src/api_routes_v3.rs b/src/api_routes_v3.rs index 574646309..8fab1b148 100644 --- a/src/api_routes_v3.rs +++ b/src/api_routes_v3.rs @@ -28,10 +28,8 @@ use lemmy_api::{ login::login, logout::logout, notifications::{ - list_mentions::list_mentions, - list_replies::list_replies, mark_all_read::mark_all_notifications_read, - mark_mention_read::mark_person_mention_as_read, + mark_comment_mention_read::mark_comment_mention_as_read, mark_reply_read::mark_reply_as_read, unread_count::unread_count, }, @@ -108,7 +106,6 @@ use lemmy_api_crud::{ private_message::{ create::create_private_message, delete::delete_private_message, - read::get_private_message, update::update_private_message, }, site::{create::create_site, read::get_site_v3, update::update_site}, @@ -254,7 +251,6 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) { .service( scope("/private_message") .wrap(rate_limit.message()) - .route("/list", get().to(get_private_message)) .route("", post().to(create_private_message)) .route("", put().to(update_private_message)) .route("/delete", post().to(delete_private_message)) @@ -315,12 +311,10 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) { scope("/user") .wrap(rate_limit.message()) .route("", get().to(read_person)) - .route("/mention", get().to(list_mentions)) .route( "/mention/mark_as_read", - post().to(mark_person_mention_as_read), + post().to(mark_comment_mention_as_read), ) - .route("/replies", get().to(list_replies)) // Admin action. I don't like that it's in /user .route("/ban", post().to(ban_from_site)) .route("/banned", get().to(list_banned_users)) diff --git a/src/api_routes_v4.rs b/src/api_routes_v4.rs index 02eb11cd7..c0eb33fab 100644 --- a/src/api_routes_v4.rs +++ b/src/api_routes_v4.rs @@ -35,10 +35,10 @@ use lemmy_api::{ login::login, logout::logout, notifications::{ - list_mentions::list_mentions, - list_replies::list_replies, + list_inbox::list_inbox, mark_all_read::mark_all_notifications_read, - mark_mention_read::mark_person_mention_as_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, unread_count::unread_count, }, @@ -125,7 +125,6 @@ use lemmy_api_crud::{ private_message::{ create::create_private_message, delete::delete_private_message, - read::get_private_message, update::update_private_message, }, site::{create::create_site, read::get_site_v4, update::update_site}, @@ -283,7 +282,6 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) { // Private Message .service( scope("/private_message") - .route("/list", get().to(get_private_message)) .route("", post().to(create_private_message)) .route("", put().to(update_private_message)) .route("/delete", post().to(delete_private_message)) @@ -314,28 +312,21 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) { .route("/verify_email", post().to(verify_email)) .route("/saved", get().to(list_person_saved)), ) - .route("/account/settings/save", put().to(save_user_settings)) - .service( - scope("/account/settings") - .wrap(rate_limit.import_user_settings()) - .route("/export", get().to(export_settings)) - .route("/import", post().to(import_settings)), - ) .service( scope("/account") .route("", get().to(get_my_user)) .route("/list_media", get().to(list_media)) - .route("/mention", get().to(list_mentions)) - .route("/replies", get().to(list_replies)) + .route("/inbox", get().to(list_inbox)) .route("/delete", post().to(delete_account)) - .route( - "/mention/mark_as_read", - post().to(mark_person_mention_as_read), - ) - .route( - "/mention/mark_as_read/all", - post().to(mark_all_notifications_read), + .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("/report_count", get().to(report_count)) .route("/unread_count", get().to(unread_count)) .route("/list_logins", get().to(list_logins)) @@ -349,6 +340,14 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimitCell) { .route("/person", post().to(user_block_person)) .route("/community", post().to(user_block_community)) .route("/instance", post().to(user_block_instance)), + ) + .route("/settings/save", put().to(save_user_settings)) + // Account settings import / export have a strict rate limit + .service( + scope("/settings") + .wrap(rate_limit.import_user_settings()) + .route("/export", get().to(export_settings)) + .route("/import", post().to(import_settings)), ), ) // User actions