Skip to content

Commit 3cea716

Browse files
authored
feat(community): add features.noReplyLinks to block replies with links (#105)
Adds a community feature flag that rejects any reply whose link field is set (media or not), closing the gap left by noImageReplies/noVideoReplies/ noAudioReplies which only block media links. Posts are unaffected. - schema: noReplyLinks boolean in CommunityFeaturesSchema - errors: ERR_REPLY_HAS_LINK - validation: block reply (parentCid set) with any link in checkCommentPublication - README: document the flag - test: post-with-link allowed; reply with media/non-media link rejected; linkless reply allowed
1 parent c3645d0 commit 3cea716

5 files changed

Lines changed: 103 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ CommunityFeatures { // any boolean that changes the functionality of the communi
209209
noVideoReplies?: boolean // block only replies with video links
210210
noImageReplies?: boolean // block only replies with image links
211211
noAudioReplies?: boolean // block only replies with audio links
212+
noReplyLinks?: boolean // block all replies that have a link field set
212213
noSpoilerReplies?: boolean // author can't set spoiler = true on replies
213214
noNestedReplies?: boolean // no nested replies, like old school forums and 4chan. Maximum depth is 1
214215
safeForWork?: boolean // informational flag indicating this community is safe for work

src/community/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const CommunityFeaturesSchema = z.looseObject({
5050
noVideoReplies: z.boolean().optional(), // Block only replies with video links
5151
noSpoilerReplies: z.boolean().optional(), // Author can't set spoiler = true on replies
5252
noImageReplies: z.boolean().optional(), // Block only replies with image links
53+
noReplyLinks: z.boolean().optional(), // Block all replies that have a link field set
5354
noPolls: z.boolean().optional(), // Not impllemented
5455
noCrossposts: z.boolean().optional(), // Not implemented
5556
noNestedReplies: z.boolean().optional(), // No nested replies, like old school forums and 4chan. Maximum depth is 1

src/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ export enum messages {
271271
ERR_REPLY_HAS_LINK_THAT_IS_VIDEO = "This community does not allow replies with video links",
272272
ERR_COMMENT_HAS_LINK_THAT_IS_AUDIO = "This community does not allow comments with audio links",
273273
ERR_REPLY_HAS_LINK_THAT_IS_AUDIO = "This community does not allow replies with audio links",
274+
ERR_REPLY_HAS_LINK = "This community does not allow replies with links",
274275
ERR_COMMENT_CONTENT_CONTAINS_MARKDOWN_AUDIO = "This community does not allow embedding audio in markdown content",
275276
ERR_REPLY_HAS_SPOILER_ENABLED = "This community does not allow authors to mark replies as spoilers",
276277
ERR_NESTED_REPLIES_NOT_ALLOWED = "This community does not allow nested replies (depth > 1)",

src/runtime/node/community/local-community/publication-validation.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,9 @@ async function checkCommentPublication(
326326
)
327327
return messages.ERR_REPLY_HAS_LINK_THAT_IS_VIDEO;
328328

329+
// noReplyLinks - block all replies that have a link field set
330+
if (community.features?.noReplyLinks && commentPublication.parentCid && commentPublication.link) return messages.ERR_REPLY_HAS_LINK;
331+
329332
// noAudio - block ALL comments with audio links
330333
if (community.features?.noAudio && commentPublication.link && isLinkOfAudio(commentPublication.link))
331334
return messages.ERR_COMMENT_HAS_LINK_THAT_IS_AUDIO;
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import {
2+
mockPKC,
3+
createSubWithNoChallenge,
4+
generateMockPost,
5+
generateMockComment,
6+
publishWithExpectedResult,
7+
mockPKCNoDataPathWithOnlyKuboClient,
8+
resolveWhenConditionIsTrue,
9+
publishRandomPost
10+
} from "../../../../dist/node/test/test-util.js";
11+
import { messages } from "../../../../dist/node/errors.js";
12+
import { describe, it, beforeAll, afterAll } from "vitest";
13+
import type { PKC } from "../../../../dist/node/pkc/pkc.js";
14+
import type { LocalCommunity } from "../../../../dist/node/runtime/node/community/local-community.js";
15+
import type { RpcLocalCommunity } from "../../../../dist/node/community/rpc-local-community.js";
16+
import type { Comment } from "../../../../dist/node/publications/comment/comment.js";
17+
import type { CommentIpfsWithCidDefined } from "../../../../dist/node/publications/comment/types.js";
18+
19+
describe.concurrent(`community.features.noReplyLinks`, async () => {
20+
let pkc: PKC;
21+
let remotePKC: PKC;
22+
let community: LocalCommunity | RpcLocalCommunity;
23+
let publishedPost: Comment;
24+
25+
beforeAll(async () => {
26+
pkc = await mockPKC();
27+
remotePKC = await mockPKCNoDataPathWithOnlyKuboClient();
28+
community = await createSubWithNoChallenge({}, pkc);
29+
await community.start();
30+
await resolveWhenConditionIsTrue({ toUpdate: community, predicate: async () => typeof community.updatedAt === "number" });
31+
32+
// Publish a post first (before enabling the feature)
33+
publishedPost = await publishRandomPost({ communityAddress: community.address, pkc: remotePKC });
34+
});
35+
36+
afterAll(async () => {
37+
await community.delete();
38+
await pkc.destroy();
39+
await remotePKC.destroy();
40+
});
41+
42+
it.sequential(`Feature is updated correctly in props`, async () => {
43+
expect(community.features).to.be.undefined;
44+
await community.edit({ features: { ...community.features, noReplyLinks: true } });
45+
expect(community.features?.noReplyLinks).to.be.true;
46+
47+
const remoteCommunity = await remotePKC.getCommunity({ address: community.address });
48+
await remoteCommunity.update();
49+
await resolveWhenConditionIsTrue({
50+
toUpdate: remoteCommunity,
51+
predicate: async () => remoteCommunity.features?.noReplyLinks === true
52+
});
53+
expect(remoteCommunity.features?.noReplyLinks).to.be.true;
54+
await remoteCommunity.stop();
55+
});
56+
57+
it(`Can publish a post with link (noReplyLinks only blocks replies)`, async () => {
58+
const post = await generateMockPost({
59+
communityAddress: community.address,
60+
pkc: remotePKC,
61+
postProps: {
62+
link: "https://example.com/article",
63+
content: "Just text"
64+
}
65+
});
66+
await publishWithExpectedResult({ publication: post, expectedChallengeSuccess: true });
67+
});
68+
69+
it(`Can't publish a reply with a non-media link`, async () => {
70+
const reply = await generateMockComment(publishedPost as CommentIpfsWithCidDefined, remotePKC, false, {
71+
link: "https://example.com/article"
72+
});
73+
await publishWithExpectedResult({
74+
publication: reply,
75+
expectedChallengeSuccess: false,
76+
expectedReason: messages.ERR_REPLY_HAS_LINK
77+
});
78+
});
79+
80+
it(`Can't publish a reply with a media link`, async () => {
81+
const reply = await generateMockComment(publishedPost as CommentIpfsWithCidDefined, remotePKC, false, {
82+
link: "https://example.com/photo.jpg"
83+
});
84+
await publishWithExpectedResult({
85+
publication: reply,
86+
expectedChallengeSuccess: false,
87+
expectedReason: messages.ERR_REPLY_HAS_LINK
88+
});
89+
});
90+
91+
it(`Can publish a reply without a link`, async () => {
92+
const reply = await generateMockComment(publishedPost as CommentIpfsWithCidDefined, remotePKC, false, {
93+
content: "Just text reply"
94+
});
95+
await publishWithExpectedResult({ publication: reply, expectedChallengeSuccess: true });
96+
});
97+
});

0 commit comments

Comments
 (0)