Skip to content

Commit 7eaf51f

Browse files
committed
feat: implement inbox for replies & mentions
1 parent f2afa6e commit 7eaf51f

File tree

6 files changed

+309
-5
lines changed

6 files changed

+309
-5
lines changed

.idea/misc.xml

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/(ui)/SearchParamLinks.tsx

+23-2
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import { StyledLink } from "@/app/(ui)/StyledLink";
44
import classNames from "classnames";
55
import { usePathname, useSearchParams } from "next/navigation";
66

7+
type Option = string | { target: string; badge?: string };
78
type Props = {
89
readonly label: string;
910
readonly searchParamKey: string;
10-
readonly options: string[];
11+
readonly options: Option[];
1112
readonly currentActiveValue?: string;
1213
readonly className?: string;
1314
};
@@ -24,9 +25,20 @@ export const SearchParamLinks = (props: Props) => {
2425
{props.label}
2526
{":"}
2627
</div>
27-
{props.options.map((target) => {
28+
{props.options.map((option) => {
29+
let badge = undefined;
30+
let target;
31+
32+
if (typeof option === "string") {
33+
target = option;
34+
} else {
35+
badge = option.badge;
36+
target = option.target;
37+
}
38+
2839
return (
2940
<SearchParamLink
41+
badge={badge}
3042
currentActiveValue={props.currentActiveValue}
3143
key={target}
3244
searchParamKey={props.searchParamKey}
@@ -42,6 +54,7 @@ const SearchParamLink = (props: {
4254
readonly currentActiveValue?: string;
4355
readonly targetValue: string;
4456
readonly searchParamKey: string;
57+
readonly badge?: string;
4558
}) => {
4659
const path = usePathname();
4760
const searchParams = useSearchParams();
@@ -59,6 +72,14 @@ const SearchParamLink = (props: {
5972
href={`${path}?${newSearchParams.toString()}`}
6073
>
6174
{props.targetValue}
75+
{props.badge && (
76+
<span
77+
className={`ml-1 rounded border border-neutral-400 px-1 py-0.5 font-mono text-[9px]
78+
font-medium text-neutral-300`}
79+
>
80+
{props.badge}
81+
</span>
82+
)}
6283
</StyledLink>
6384
);
6485
};

src/app/inbox/InboxMention.tsx

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"use client";
2+
3+
import { MyUserInfo, PersonMentionView, SiteView } from "lemmy-js-client";
4+
import { useState } from "react";
5+
import { Comment } from "@/app/comment/Comment";
6+
import { getVoteConfig } from "@/app/(ui)/vote/getVoteConfig";
7+
import { toggleMentionRead } from "@/app/inbox/inboxActions";
8+
import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
9+
import { MarkdownProps } from "@/app/(ui)/markdown/Markdown";
10+
import { EnvelopeIcon, EnvelopeOpenIcon } from "@heroicons/react/16/solid";
11+
12+
export const InboxMention = (props: {
13+
readonly loggedInUser: MyUserInfo;
14+
readonly personMentionView: PersonMentionView;
15+
readonly siteView: SiteView;
16+
readonly markdown: MarkdownProps;
17+
}) => {
18+
const [read, setRead] = useState(props.personMentionView.person_mention.read);
19+
20+
return (
21+
<div className={"py-4"}>
22+
<Comment
23+
addPostLink={true}
24+
commentView={props.personMentionView}
25+
loggedInUser={props.loggedInUser}
26+
markdown={props.markdown}
27+
voteConfig={getVoteConfig(
28+
props.siteView.local_site,
29+
props.loggedInUser,
30+
)}
31+
/>
32+
<form
33+
action={async () => {
34+
const newState = !read;
35+
const action = toggleMentionRead.bind(
36+
null,
37+
props.personMentionView.person_mention.id,
38+
newState,
39+
);
40+
await action();
41+
setRead(newState);
42+
}}
43+
className={"ml-6 mt-2"}
44+
>
45+
<SubmitButton
46+
className={"gap-1"}
47+
color={read ? "neutral" : "primary"}
48+
size={"xs"}
49+
>
50+
{read && <EnvelopeIcon className={"h-4"} />}
51+
{!read && <EnvelopeOpenIcon className={"h-4"} />}
52+
{`Mark as ${read ? "unread" : "read"}`}
53+
</SubmitButton>
54+
</form>
55+
</div>
56+
);
57+
};

src/app/inbox/InboxReply.tsx

+57
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"use client";
2+
3+
import { CommentReplyView, MyUserInfo, SiteView } from "lemmy-js-client";
4+
import { useState } from "react";
5+
import { Comment } from "@/app/comment/Comment";
6+
import { getVoteConfig } from "@/app/(ui)/vote/getVoteConfig";
7+
import { toggleReplyRead } from "@/app/inbox/inboxActions";
8+
import { SubmitButton } from "@/app/(ui)/button/SubmitButton";
9+
import { MarkdownProps } from "@/app/(ui)/markdown/Markdown";
10+
import { EnvelopeIcon, EnvelopeOpenIcon } from "@heroicons/react/16/solid";
11+
12+
export const InboxReply = (props: {
13+
readonly loggedInUser: MyUserInfo;
14+
readonly commentReplyView: CommentReplyView;
15+
readonly siteView: SiteView;
16+
readonly markdown: MarkdownProps;
17+
}) => {
18+
const [read, setRead] = useState(props.commentReplyView.comment_reply.read);
19+
20+
return (
21+
<div className={"py-4"}>
22+
<Comment
23+
addPostLink={true}
24+
commentView={props.commentReplyView}
25+
loggedInUser={props.loggedInUser}
26+
markdown={props.markdown}
27+
voteConfig={getVoteConfig(
28+
props.siteView.local_site,
29+
props.loggedInUser,
30+
)}
31+
/>
32+
<form
33+
action={async () => {
34+
const newState = !read;
35+
const action = toggleReplyRead.bind(
36+
null,
37+
props.commentReplyView.comment_reply.id,
38+
newState,
39+
);
40+
await action();
41+
setRead(newState);
42+
}}
43+
className={"ml-6 mt-2"}
44+
>
45+
<SubmitButton
46+
className={"gap-1"}
47+
color={read ? "neutral" : "primary"}
48+
size={"xs"}
49+
>
50+
{read && <EnvelopeIcon className={"h-4"} />}
51+
{!read && <EnvelopeOpenIcon className={"h-4"} />}
52+
{`Mark as ${read ? "unread" : "read"}`}
53+
</SubmitButton>
54+
</form>
55+
</div>
56+
);
57+
};

src/app/inbox/inboxActions.ts

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"use server";
2+
3+
import { apiClient } from "@/app/apiClient";
4+
import { revalidatePath } from "next/cache";
5+
6+
export const toggleMentionRead = async (mentionId: number, read: boolean) => {
7+
await apiClient.markPersonMentionAsRead({
8+
person_mention_id: mentionId,
9+
read,
10+
});
11+
12+
revalidatePath("/inbox");
13+
};
14+
15+
export const toggleReplyRead = async (replyId: number, read: boolean) => {
16+
await apiClient.markCommentReplyAsRead({
17+
comment_reply_id: replyId,
18+
read,
19+
});
20+
21+
revalidatePath("/inbox");
22+
};

src/app/inbox/page.tsx

+147-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,151 @@
1-
import { NotImplemented } from "@/app/(ui)/NotImplemented";
1+
import { apiClient } from "@/app/apiClient";
2+
import { loginPageWithRedirectAction } from "@/app/login/authActions";
3+
import { SearchParamLinks } from "@/app/(ui)/SearchParamLinks";
4+
import { InboxMention } from "@/app/inbox/InboxMention";
5+
import { getMarkdownWithRemoteImagesAction } from "@/app/(ui)/markdown/markdownActions";
6+
import { InboxReply } from "@/app/inbox/InboxReply";
7+
import { Pagination } from "@/app/(ui)/Pagination";
28

3-
const InboxPage = () => {
4-
return <NotImplemented />;
9+
type InboxType = "Replies" | "Messages" | "Mentions";
10+
const InboxPage = async (props: {
11+
readonly searchParams: {
12+
type: InboxType;
13+
page: number;
14+
filter: "Unread" | "All";
15+
};
16+
}) => {
17+
const { site_view: siteView, my_user: loggedInUser } =
18+
await apiClient.getSite();
19+
if (!loggedInUser) {
20+
await loginPageWithRedirectAction(`/inbox`, true);
21+
return;
22+
}
23+
24+
const unreadCounts = await apiClient.getUnreadCount();
25+
26+
const currentPage = props.searchParams.page
27+
? Number(props.searchParams.page)
28+
: 1;
29+
const currentType = props.searchParams.type ?? "Replies";
30+
const currentFilter = props.searchParams.filter ?? "Unread";
31+
32+
const limit = 20;
33+
34+
let replies = null;
35+
let messages = null;
36+
let mentions = null;
37+
38+
switch (currentType) {
39+
case "Replies":
40+
replies = (
41+
await apiClient.getReplies({
42+
page: currentPage,
43+
limit,
44+
unread_only: currentFilter !== "All",
45+
sort: "New",
46+
})
47+
).replies;
48+
break;
49+
case "Messages":
50+
messages = (
51+
await apiClient.getPrivateMessages({
52+
page: currentPage,
53+
limit,
54+
unread_only: currentFilter !== "All",
55+
})
56+
).private_messages;
57+
break;
58+
case "Mentions":
59+
mentions = (
60+
await apiClient.getPersonMentions({
61+
page: currentPage,
62+
limit,
63+
unread_only: currentFilter !== "All",
64+
sort: "New",
65+
})
66+
).mentions;
67+
break;
68+
}
69+
70+
const nextPageAvailable =
71+
mentions?.length === limit ||
72+
messages?.length === limit ||
73+
replies?.length === limit;
74+
75+
const typeOptions = [
76+
{
77+
target: "Replies",
78+
badge: unreadCounts.replies ? `${unreadCounts.replies}` : undefined,
79+
},
80+
{
81+
target: "Messages",
82+
badge: unreadCounts.private_messages
83+
? `${unreadCounts.private_messages}`
84+
: undefined,
85+
},
86+
{
87+
target: "Mentions",
88+
badge: unreadCounts.mentions ? `${unreadCounts.mentions}` : undefined,
89+
},
90+
];
91+
92+
return (
93+
<div className={"m-2 lg:m-4"}>
94+
<SearchParamLinks
95+
currentActiveValue={currentType}
96+
label={"Type"}
97+
options={typeOptions}
98+
searchParamKey={"type"}
99+
/>
100+
<SearchParamLinks
101+
currentActiveValue={currentFilter}
102+
label={"Filter"}
103+
options={["Unread", "All"]}
104+
searchParamKey={"filter"}
105+
/>
106+
<div className={"divide-y-2 divide-dotted divide-gray-600"}>
107+
{mentions &&
108+
mentions.map(async (personMentionView) => (
109+
<InboxMention
110+
key={personMentionView.comment.id}
111+
loggedInUser={loggedInUser}
112+
markdown={{
113+
...(await getMarkdownWithRemoteImagesAction(
114+
personMentionView.comment.content,
115+
`comment-${personMentionView.comment.id}`,
116+
)),
117+
localSiteName: siteView.site.name,
118+
}}
119+
personMentionView={personMentionView}
120+
siteView={siteView}
121+
/>
122+
))}
123+
{replies &&
124+
replies.map(async (replyView) => (
125+
<InboxReply
126+
commentReplyView={replyView}
127+
key={replyView.comment.id}
128+
loggedInUser={loggedInUser}
129+
markdown={{
130+
...(await getMarkdownWithRemoteImagesAction(
131+
replyView.comment.content,
132+
`comment-${replyView.comment.id}`,
133+
)),
134+
localSiteName: siteView.site.name,
135+
}}
136+
siteView={siteView}
137+
/>
138+
))}
139+
{!replies?.length && !mentions?.length && !messages?.length && (
140+
<div className={"mt-4"}>{"Nothing here!"}</div>
141+
)}
142+
</div>
143+
<Pagination
144+
nextPage={nextPageAvailable ? currentPage + 1 : undefined}
145+
prevPage={currentPage > 1 ? currentPage - 1 : undefined}
146+
/>
147+
</div>
148+
);
5149
};
6150

7151
export default InboxPage;

0 commit comments

Comments
 (0)