From 3c36cc2953d4440936d53b19a0ba194e37a8d8ec Mon Sep 17 00:00:00 2001 From: Steven Date: Sat, 28 Oct 2023 02:43:46 +0800 Subject: [PATCH] feat: add inbox ui --- api/v1/memo.go | 28 +++++- api/v2/resource_name.go | 2 +- store/db/sqlite/inbox.go | 4 +- store/inbox.go | 4 + web/src/components/Header.tsx | 8 +- .../components/Inbox/MemoCommentMessage.tsx | 92 +++++++++++++++++++ web/src/grpcweb.ts | 3 + web/src/locales/en.json | 3 +- web/src/pages/Inboxes.tsx | 51 ++++++++++ web/src/router/index.tsx | 6 ++ web/src/store/v1/inbox.ts | 32 +++++++ 11 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 web/src/components/Inbox/MemoCommentMessage.tsx create mode 100644 web/src/pages/Inboxes.tsx create mode 100644 web/src/store/v1/inbox.ts diff --git a/api/v1/memo.go b/api/v1/memo.go index c1ac9133..df5ba6c7 100644 --- a/api/v1/memo.go +++ b/api/v1/memo.go @@ -14,6 +14,7 @@ import ( "github.com/usememos/memos/internal/log" "github.com/usememos/memos/internal/util" + storepb "github.com/usememos/memos/proto/gen/store" "github.com/usememos/memos/server/service/metric" "github.com/usememos/memos/store" ) @@ -344,9 +345,32 @@ func (s *APIV1Service) CreateMemo(c echo.Context) error { if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get related memo").SetInternal(err) } - // nolint if relatedMemo.CreatorID != memo.CreatorID { - // TODO: When a memo is commented by others, send notification to the memo creator. + activity, err := s.Store.CreateActivity(ctx, &store.Activity{ + CreatorID: memo.CreatorID, + Type: store.ActivityTypeMemoComment, + Level: store.ActivityLevelInfo, + Payload: &storepb.ActivityPayload{ + MemoComment: &storepb.ActivityMemoCommentPayload{ + MemoId: memo.ID, + RelatedMemoId: memoRelationUpsert.RelatedMemoID, + }, + }, + }) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create activity").SetInternal(err) + } + if _, err := s.Store.CreateInbox(ctx, &store.Inbox{ + SenderID: memo.CreatorID, + ReceiverID: relatedMemo.CreatorID, + Status: store.UNREAD, + Message: &storepb.InboxMessage{ + Type: storepb.InboxMessage_TYPE_MEMO_COMMENT, + ActivityId: &activity.ID, + }, + }); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create inbox").SetInternal(err) + } } } } diff --git a/api/v2/resource_name.go b/api/v2/resource_name.go index da028864..21e903cb 100644 --- a/api/v2/resource_name.go +++ b/api/v2/resource_name.go @@ -9,7 +9,7 @@ import ( ) const ( - InboxNamePrefix = "inbox/" + InboxNamePrefix = "inboxes/" ) // GetNameParentTokens returns the tokens from a resource name. diff --git a/store/db/sqlite/inbox.go b/store/db/sqlite/inbox.go index bb99e91a..1ea3e4b9 100644 --- a/store/db/sqlite/inbox.go +++ b/store/db/sqlite/inbox.go @@ -52,7 +52,7 @@ func (d *DB) ListInboxes(ctx context.Context, find *store.FindInbox) ([]*store.I where, args = append(where, "`status` = ?"), append(args, *find.Status) } - query := "SELECT `id`, `created_ts`, `sender_id`, `receiver_id`, `status`, `message` FROM `inbox` WHERE " + strings.Join(where, " AND ") + query := "SELECT `id`, `created_ts`, `sender_id`, `receiver_id`, `status`, `message` FROM `inbox` WHERE " + strings.Join(where, " AND ") + " ORDER BY created_ts DESC" rows, err := d.db.QueryContext(ctx, query, args...) if err != nil { return nil, err @@ -90,7 +90,7 @@ func (d *DB) ListInboxes(ctx context.Context, find *store.FindInbox) ([]*store.I } func (d *DB) UpdateInbox(ctx context.Context, update *store.UpdateInbox) (*store.Inbox, error) { - set, args := []string{"status"}, []any{update.Status} + set, args := []string{"status"}, []any{update.Status.String()} args = append(args, update.ID) query := "UPDATE inbox SET " + strings.Join(set, " = ?, ") + " = ? WHERE id = ? RETURNING `id`, `created_ts`, `sender_id`, `receiver_id`, `status`, `message`" inbox := &store.Inbox{} diff --git a/store/inbox.go b/store/inbox.go index fd3e4071..b36214d4 100644 --- a/store/inbox.go +++ b/store/inbox.go @@ -15,6 +15,10 @@ const ( ARCHIVED InboxStatus = "ARCHIVED" ) +func (s InboxStatus) String() string { + return string(s) +} + type Inbox struct { ID int32 CreatedTs int64 diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx index d6753f1d..f1273bb7 100644 --- a/web/src/components/Header.tsx +++ b/web/src/components/Header.tsx @@ -52,6 +52,12 @@ const Header = () => { title: t("common.resources"), icon: , }; + const inboxNavLink: NavLinkItem = { + id: "header-inbox", + path: "/inbox", + title: t("common.inbox"), + icon: , + }; const exploreNavLink: NavLinkItem = { id: "header-explore", path: "/explore", @@ -78,7 +84,7 @@ const Header = () => { }; const navLinks: NavLinkItem[] = user - ? [homeNavLink, dailyReviewNavLink, resourcesNavLink, exploreNavLink, archivedNavLink, settingNavLink] + ? [homeNavLink, dailyReviewNavLink, resourcesNavLink, inboxNavLink, exploreNavLink, archivedNavLink, settingNavLink] : [exploreNavLink, signInNavLink]; return ( diff --git a/web/src/components/Inbox/MemoCommentMessage.tsx b/web/src/components/Inbox/MemoCommentMessage.tsx new file mode 100644 index 00000000..cce224ee --- /dev/null +++ b/web/src/components/Inbox/MemoCommentMessage.tsx @@ -0,0 +1,92 @@ +import { Tooltip } from "@mui/joy"; +import classNames from "classnames"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { activityServiceClient } from "@/grpcweb"; +import useNavigateTo from "@/hooks/useNavigateTo"; +import useInboxStore from "@/store/v1/inbox"; +import { Activity } from "@/types/proto/api/v2/activity_service"; +import { Inbox, Inbox_Status } from "@/types/proto/api/v2/inbox_service"; +import Icon from "../Icon"; + +interface Props { + inbox: Inbox; +} + +const MemoCommentMessage = ({ inbox }: Props) => { + const navigateTo = useNavigateTo(); + const inboxStore = useInboxStore(); + const [activity, setActivity] = useState(undefined); + + useEffect(() => { + if (!inbox.activityId) { + return; + } + + activityServiceClient + .getActivity({ + id: inbox.activityId, + }) + .then(({ activity }) => { + setActivity(activity); + }); + }, [inbox.activityId]); + + const handleNavigateToMemo = () => { + if (!activity?.payload?.memoComment?.relatedMemoId) { + return; + } + navigateTo(`/m/${activity?.payload?.memoComment?.relatedMemoId}`); + }; + + const handleArchiveMessage = async () => { + await inboxStore.updateInbox( + { + name: inbox.name, + status: Inbox_Status.ARCHIVED, + }, + ["status"] + ); + toast.success("Archived"); + }; + + return ( +
+
+ +
+
+
+ {inbox.createTime?.toLocaleString()} +
+ {inbox.status === Inbox_Status.UNREAD && ( + + + + )} +
+
+

+ {inbox.sender} has a comment in your memo #{activity?.payload?.memoComment?.relatedMemoId}. +

+
+
+ ); +}; + +export default MemoCommentMessage; diff --git a/web/src/grpcweb.ts b/web/src/grpcweb.ts index 154b66e1..432c2d39 100644 --- a/web/src/grpcweb.ts +++ b/web/src/grpcweb.ts @@ -1,4 +1,5 @@ import { createChannel, createClientFactory, FetchTransport } from "nice-grpc-web"; +import { ActivityServiceDefinition } from "./types/proto/api/v2/activity_service"; import { InboxServiceDefinition } from "./types/proto/api/v2/inbox_service"; import { MemoServiceDefinition } from "./types/proto/api/v2/memo_service"; import { ResourceServiceDefinition } from "./types/proto/api/v2/resource_service"; @@ -26,3 +27,5 @@ export const systemServiceClient = clientFactory.create(SystemServiceDefinition, export const tagServiceClient = clientFactory.create(TagServiceDefinition, channel); export const inboxServiceClient = clientFactory.create(InboxServiceDefinition, channel); + +export const activityServiceClient = clientFactory.create(ActivityServiceDefinition, channel); diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 6928d912..ad0762fc 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -61,7 +61,8 @@ "beta": "Beta", "new": "New", "mark": "Mark", - "profile": "Profile" + "profile": "Profile", + "inbox": "Inbox" }, "router": { "go-to-home": "Go to Home", diff --git a/web/src/pages/Inboxes.tsx b/web/src/pages/Inboxes.tsx new file mode 100644 index 00000000..55f65330 --- /dev/null +++ b/web/src/pages/Inboxes.tsx @@ -0,0 +1,51 @@ +import { useEffect } from "react"; +import Empty from "@/components/Empty"; +import Icon from "@/components/Icon"; +import MemoCommentMessage from "@/components/Inbox/MemoCommentMessage"; +import MobileHeader from "@/components/MobileHeader"; +import useInboxStore from "@/store/v1/inbox"; +import { Inbox_Type } from "@/types/proto/api/v2/inbox_service"; +import { useTranslate } from "@/utils/i18n"; + +const Inboxes = () => { + const t = useTranslate(); + const inboxStore = useInboxStore(); + const inboxes = inboxStore.inboxes.sort((a, b) => { + return a.status - b.status; + }); + + useEffect(() => { + inboxStore.fetchInboxes(); + }, []); + + return ( +
+ +
+
+

+ {t("common.inbox")} +

+
+
+ {inboxes.length === 0 && ( +
+ +

{t("message.no-data")}

+
+ )} +
+ {inboxes.map((inbox) => { + if (inbox.type === Inbox_Type.TYPE_MEMO_COMMENT) { + return ; + } + return undefined; + })} +
+
+
+
+ ); +}; + +export default Inboxes; diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 26504218..cba2eabe 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -15,6 +15,7 @@ const EmbedMemo = lazy(() => import("@/pages/EmbedMemo")); const Archived = lazy(() => import("@/pages/Archived")); const DailyReview = lazy(() => import("@/pages/DailyReview")); const Resources = lazy(() => import("@/pages/Resources")); +const Inboxes = lazy(() => import("@/pages/Inboxes")); const Setting = lazy(() => import("@/pages/Setting")); const NotFound = lazy(() => import("@/pages/NotFound")); @@ -83,6 +84,11 @@ const router = createBrowserRouter([ element: , loader: () => initialUserStateLoader(), }, + { + path: "inbox", + element: , + loader: () => initialUserStateLoader(), + }, { path: "archived", element: , diff --git a/web/src/store/v1/inbox.ts b/web/src/store/v1/inbox.ts new file mode 100644 index 00000000..982d73b2 --- /dev/null +++ b/web/src/store/v1/inbox.ts @@ -0,0 +1,32 @@ +import { create } from "zustand"; +import { inboxServiceClient } from "@/grpcweb"; +import { Inbox } from "@/types/proto/api/v2/inbox_service"; + +interface InboxStore { + inboxes: Inbox[]; + fetchInboxes: () => Promise; + updateInbox: (inbox: Partial, updateMask: string[]) => Promise; +} + +const useInboxStore = create()((set, get) => ({ + inboxes: [], + fetchInboxes: async () => { + const { inboxes } = await inboxServiceClient.listInboxes({}); + set({ inboxes }); + return inboxes; + }, + updateInbox: async (inbox: Partial, updateMask: string[]) => { + const { inbox: updatedInbox } = await inboxServiceClient.updateInbox({ + inbox, + updateMask, + }); + if (!updatedInbox) { + throw new Error("Inbox not found"); + } + const inboxes = get().inboxes; + set({ inboxes: inboxes.map((i) => (i.name === updatedInbox.name ? updatedInbox : i)) }); + return updatedInbox; + }, +})); + +export default useInboxStore;