mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
feat: add inbox ui
This commit is contained in:
@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/usememos/memos/internal/log"
|
"github.com/usememos/memos/internal/log"
|
||||||
"github.com/usememos/memos/internal/util"
|
"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/server/service/metric"
|
||||||
"github.com/usememos/memos/store"
|
"github.com/usememos/memos/store"
|
||||||
)
|
)
|
||||||
@ -344,9 +345,32 @@ func (s *APIV1Service) CreateMemo(c echo.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get related memo").SetInternal(err)
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to get related memo").SetInternal(err)
|
||||||
}
|
}
|
||||||
// nolint
|
|
||||||
if relatedMemo.CreatorID != memo.CreatorID {
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
InboxNamePrefix = "inbox/"
|
InboxNamePrefix = "inboxes/"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetNameParentTokens returns the tokens from a resource name.
|
// GetNameParentTokens returns the tokens from a resource name.
|
||||||
|
@ -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)
|
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...)
|
rows, err := d.db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
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)
|
args = append(args, update.ID)
|
||||||
query := "UPDATE inbox SET " + strings.Join(set, " = ?, ") + " = ? WHERE id = ? RETURNING `id`, `created_ts`, `sender_id`, `receiver_id`, `status`, `message`"
|
query := "UPDATE inbox SET " + strings.Join(set, " = ?, ") + " = ? WHERE id = ? RETURNING `id`, `created_ts`, `sender_id`, `receiver_id`, `status`, `message`"
|
||||||
inbox := &store.Inbox{}
|
inbox := &store.Inbox{}
|
||||||
|
@ -15,6 +15,10 @@ const (
|
|||||||
ARCHIVED InboxStatus = "ARCHIVED"
|
ARCHIVED InboxStatus = "ARCHIVED"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (s InboxStatus) String() string {
|
||||||
|
return string(s)
|
||||||
|
}
|
||||||
|
|
||||||
type Inbox struct {
|
type Inbox struct {
|
||||||
ID int32
|
ID int32
|
||||||
CreatedTs int64
|
CreatedTs int64
|
||||||
|
@ -52,6 +52,12 @@ const Header = () => {
|
|||||||
title: t("common.resources"),
|
title: t("common.resources"),
|
||||||
icon: <Icon.Paperclip className="mr-3 w-6 h-auto opacity-70" />,
|
icon: <Icon.Paperclip className="mr-3 w-6 h-auto opacity-70" />,
|
||||||
};
|
};
|
||||||
|
const inboxNavLink: NavLinkItem = {
|
||||||
|
id: "header-inbox",
|
||||||
|
path: "/inbox",
|
||||||
|
title: t("common.inbox"),
|
||||||
|
icon: <Icon.Bell className="mr-3 w-6 h-auto opacity-70" />,
|
||||||
|
};
|
||||||
const exploreNavLink: NavLinkItem = {
|
const exploreNavLink: NavLinkItem = {
|
||||||
id: "header-explore",
|
id: "header-explore",
|
||||||
path: "/explore",
|
path: "/explore",
|
||||||
@ -78,7 +84,7 @@ const Header = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navLinks: NavLinkItem[] = user
|
const navLinks: NavLinkItem[] = user
|
||||||
? [homeNavLink, dailyReviewNavLink, resourcesNavLink, exploreNavLink, archivedNavLink, settingNavLink]
|
? [homeNavLink, dailyReviewNavLink, resourcesNavLink, inboxNavLink, exploreNavLink, archivedNavLink, settingNavLink]
|
||||||
: [exploreNavLink, signInNavLink];
|
: [exploreNavLink, signInNavLink];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
92
web/src/components/Inbox/MemoCommentMessage.tsx
Normal file
92
web/src/components/Inbox/MemoCommentMessage.tsx
Normal file
@ -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<Activity | undefined>(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 (
|
||||||
|
<div className="w-full flex flex-row justify-start items-start gap-3">
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"shrink-0 mt-2 p-2 rounded-full border",
|
||||||
|
inbox.status === Inbox_Status.UNREAD
|
||||||
|
? "border-blue-600 text-blue-600 bg-blue-50 dark:bg-zinc-800"
|
||||||
|
: "border-gray-400 text-gray-400 bg-gray-50 dark:bg-zinc-800"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon.MessageCircle className="w-4 sm:w-5 h-auto" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"border w-full p-3 sm:p-4 rounded-lg flex flex-col justify-start items-start gap-2 dark:border-zinc-800 hover:bg-gray-100 dark:hover:bg-zinc-800",
|
||||||
|
inbox.status !== Inbox_Status.UNREAD && "opacity-60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-full flex flex-row justify-between items-center">
|
||||||
|
<span className="text-sm text-gray-500">{inbox.createTime?.toLocaleString()}</span>
|
||||||
|
<div>
|
||||||
|
{inbox.status === Inbox_Status.UNREAD && (
|
||||||
|
<Tooltip title="Archive" placement="top">
|
||||||
|
<Icon.Inbox className="w-4 h-auto cursor-pointer text-gray-400 hover:text-blue-600" onClick={handleArchiveMessage} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="text-base leading-tight cursor-pointer text-gray-500 dark:text-gray-400 hover:underline hover:text-blue-600"
|
||||||
|
onClick={handleNavigateToMemo}
|
||||||
|
>
|
||||||
|
{inbox.sender} has a comment in your memo #{activity?.payload?.memoComment?.relatedMemoId}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MemoCommentMessage;
|
@ -1,4 +1,5 @@
|
|||||||
import { createChannel, createClientFactory, FetchTransport } from "nice-grpc-web";
|
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 { InboxServiceDefinition } from "./types/proto/api/v2/inbox_service";
|
||||||
import { MemoServiceDefinition } from "./types/proto/api/v2/memo_service";
|
import { MemoServiceDefinition } from "./types/proto/api/v2/memo_service";
|
||||||
import { ResourceServiceDefinition } from "./types/proto/api/v2/resource_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 tagServiceClient = clientFactory.create(TagServiceDefinition, channel);
|
||||||
|
|
||||||
export const inboxServiceClient = clientFactory.create(InboxServiceDefinition, channel);
|
export const inboxServiceClient = clientFactory.create(InboxServiceDefinition, channel);
|
||||||
|
|
||||||
|
export const activityServiceClient = clientFactory.create(ActivityServiceDefinition, channel);
|
||||||
|
@ -61,7 +61,8 @@
|
|||||||
"beta": "Beta",
|
"beta": "Beta",
|
||||||
"new": "New",
|
"new": "New",
|
||||||
"mark": "Mark",
|
"mark": "Mark",
|
||||||
"profile": "Profile"
|
"profile": "Profile",
|
||||||
|
"inbox": "Inbox"
|
||||||
},
|
},
|
||||||
"router": {
|
"router": {
|
||||||
"go-to-home": "Go to Home",
|
"go-to-home": "Go to Home",
|
||||||
|
51
web/src/pages/Inboxes.tsx
Normal file
51
web/src/pages/Inboxes.tsx
Normal file
@ -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 (
|
||||||
|
<section className="@container w-full max-w-3xl min-h-full flex flex-col justify-start items-center px-4 sm:px-2 sm:pt-4 pb-8 bg-zinc-100 dark:bg-zinc-800">
|
||||||
|
<MobileHeader showSearch={false} />
|
||||||
|
<div className="w-full shadow flex flex-col justify-start items-start px-4 py-3 rounded-xl bg-white dark:bg-zinc-700 text-black dark:text-gray-300">
|
||||||
|
<div className="relative w-full flex flex-row justify-between items-center">
|
||||||
|
<p className="px-2 py-1 flex flex-row justify-start items-center select-none opacity-80">
|
||||||
|
<Icon.Bell className="w-5 h-auto mr-1" /> {t("common.inbox")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-auto flex flex-col justify-start items-start px-2 pb-4 bg-white dark:bg-zinc-700">
|
||||||
|
{inboxes.length === 0 && (
|
||||||
|
<div className="w-full mt-4 mb-8 flex flex-col justify-center items-center italic">
|
||||||
|
<Empty />
|
||||||
|
<p className="mt-4 text-gray-600 dark:text-gray-400">{t("message.no-data")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col justify-start items-start w-full mt-4 gap-4">
|
||||||
|
{inboxes.map((inbox) => {
|
||||||
|
if (inbox.type === Inbox_Type.TYPE_MEMO_COMMENT) {
|
||||||
|
return <MemoCommentMessage key={`${inbox.name}-${inbox.status}`} inbox={inbox} />;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Inboxes;
|
@ -15,6 +15,7 @@ const EmbedMemo = lazy(() => import("@/pages/EmbedMemo"));
|
|||||||
const Archived = lazy(() => import("@/pages/Archived"));
|
const Archived = lazy(() => import("@/pages/Archived"));
|
||||||
const DailyReview = lazy(() => import("@/pages/DailyReview"));
|
const DailyReview = lazy(() => import("@/pages/DailyReview"));
|
||||||
const Resources = lazy(() => import("@/pages/Resources"));
|
const Resources = lazy(() => import("@/pages/Resources"));
|
||||||
|
const Inboxes = lazy(() => import("@/pages/Inboxes"));
|
||||||
const Setting = lazy(() => import("@/pages/Setting"));
|
const Setting = lazy(() => import("@/pages/Setting"));
|
||||||
const NotFound = lazy(() => import("@/pages/NotFound"));
|
const NotFound = lazy(() => import("@/pages/NotFound"));
|
||||||
|
|
||||||
@ -83,6 +84,11 @@ const router = createBrowserRouter([
|
|||||||
element: <Resources />,
|
element: <Resources />,
|
||||||
loader: () => initialUserStateLoader(),
|
loader: () => initialUserStateLoader(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "inbox",
|
||||||
|
element: <Inboxes />,
|
||||||
|
loader: () => initialUserStateLoader(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "archived",
|
path: "archived",
|
||||||
element: <Archived />,
|
element: <Archived />,
|
||||||
|
32
web/src/store/v1/inbox.ts
Normal file
32
web/src/store/v1/inbox.ts
Normal file
@ -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<Inbox[]>;
|
||||||
|
updateInbox: (inbox: Partial<Inbox>, updateMask: string[]) => Promise<Inbox>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useInboxStore = create<InboxStore>()((set, get) => ({
|
||||||
|
inboxes: [],
|
||||||
|
fetchInboxes: async () => {
|
||||||
|
const { inboxes } = await inboxServiceClient.listInboxes({});
|
||||||
|
set({ inboxes });
|
||||||
|
return inboxes;
|
||||||
|
},
|
||||||
|
updateInbox: async (inbox: Partial<Inbox>, 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;
|
Reference in New Issue
Block a user