Add support for hidden comments (#7679)

* Add 'hidden' field to comment and reply requests

Signed-off-by: Ryan Wang <i@ryanc.cc>

* Add support for filtering comments with hidden

* Specify hidden=false and approved=true for anonymous users

* Set default hidden flag only if null in comments

* Add 'private reply' option to comment modals

* Add private tag for hidden comments and replies

* Allow hiding comments only

* Enhance comment visibility logic to allow owners to view hidden comments

* Remove hidden input for reply form

Signed-off-by: Ryan Wang <i@ryanc.cc>

* Refine i18n

Signed-off-by: Ryan Wang <i@ryanc.cc>

---------

Signed-off-by: Ryan Wang <i@ryanc.cc>
Co-authored-by: John Niang <johnniang@foxmail.com>
pull/7711/head
Ryan Wang 2025-08-19 14:47:37 +08:00 committed by GitHub
parent 3f5b69d5d0
commit 3487132154
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 223 additions and 84 deletions

View File

@ -3597,7 +3597,7 @@
}, },
"/apis/api.console.halo.run/v1alpha1/posts/{name}/unpublish": { "/apis/api.console.halo.run/v1alpha1/posts/{name}/unpublish": {
"put": { "put": {
"description": "Publish a post.", "description": "UnPublish a post.",
"operationId": "UnpublishPost", "operationId": "UnpublishPost",
"parameters": [ "parameters": [
{ {
@ -16462,6 +16462,10 @@
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
}, },
"hidden": {
"type": "boolean",
"default": false
},
"owner": { "owner": {
"$ref": "#/components/schemas/CommentEmailOwner" "$ref": "#/components/schemas/CommentEmailOwner"
}, },
@ -21328,6 +21332,10 @@
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
}, },
"hidden": {
"type": "boolean",
"default": false
},
"owner": { "owner": {
"$ref": "#/components/schemas/CommentEmailOwner" "$ref": "#/components/schemas/CommentEmailOwner"
}, },

View File

@ -1464,7 +1464,7 @@
}, },
"/apis/api.console.halo.run/v1alpha1/posts/{name}/unpublish": { "/apis/api.console.halo.run/v1alpha1/posts/{name}/unpublish": {
"put": { "put": {
"description": "Publish a post.", "description": "UnPublish a post.",
"operationId": "UnpublishPost", "operationId": "UnpublishPost",
"parameters": [ "parameters": [
{ {
@ -3868,6 +3868,10 @@
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
}, },
"hidden": {
"type": "boolean",
"default": false
},
"owner": { "owner": {
"$ref": "#/components/schemas/CommentEmailOwner" "$ref": "#/components/schemas/CommentEmailOwner"
}, },
@ -5643,6 +5647,10 @@
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
}, },
"hidden": {
"type": "boolean",
"default": false
},
"owner": { "owner": {
"$ref": "#/components/schemas/CommentEmailOwner" "$ref": "#/components/schemas/CommentEmailOwner"
}, },

View File

@ -1455,6 +1455,10 @@
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
}, },
"hidden": {
"type": "boolean",
"default": false
},
"owner": { "owner": {
"$ref": "#/components/schemas/CommentEmailOwner" "$ref": "#/components/schemas/CommentEmailOwner"
}, },
@ -2826,6 +2830,10 @@
"minLength": 1, "minLength": 1,
"type": "string" "type": "string"
}, },
"hidden": {
"type": "boolean",
"default": false
},
"owner": { "owner": {
"$ref": "#/components/schemas/CommentEmailOwner" "$ref": "#/components/schemas/CommentEmailOwner"
}, },

View File

@ -22,6 +22,14 @@ public interface UserService {
Mono<User> grantRoles(String username, Set<String> roles); Mono<User> grantRoles(String username, Set<String> roles);
/**
* Check if the user has sufficient roles.
*
* @param roles roles to check
* @return a Mono that emits true if the user has all the roles, false otherwise
*/
Mono<Boolean> hasSufficientRoles(Collection<String> roles);
Mono<User> signUp(SignUpData signUpData); Mono<User> signUp(SignUpData signUpData);
Mono<User> createUser(User user, Set<String> roles); Mono<User> createUser(User user, Set<String> roles);

View File

@ -32,6 +32,9 @@ public class CommentRequest {
@Schema(defaultValue = "false") @Schema(defaultValue = "false")
private Boolean allowNotification; private Boolean allowNotification;
@Schema(defaultValue = "false")
private Boolean hidden;
/** /**
* Converts {@link CommentRequest} to {@link Comment}. * Converts {@link CommentRequest} to {@link Comment}.
* *
@ -48,6 +51,7 @@ public class CommentRequest {
spec.setRaw(raw); spec.setRaw(raw);
spec.setContent(content); spec.setContent(content);
spec.setAllowNotification(allowNotification); spec.setAllowNotification(allowNotification);
spec.setHidden(hidden);
if (owner != null) { if (owner != null) {
spec.setOwner(owner.toCommentOwner()); spec.setOwner(owner.toCommentOwner());

View File

@ -107,7 +107,10 @@ public class CommentServiceImpl extends AbstractCommentService implements Commen
comment.getSpec().setCreationTime(Instant.now()); comment.getSpec().setCreationTime(Instant.now());
} }
comment.getSpec().setHidden(false); if (comment.getSpec().getHidden() == null) {
comment.getSpec().setHidden(false);
}
return Mono.just(comment); return Mono.just(comment);
}) })
.flatMap(populatedComment -> Mono.when(populateOwner(populatedComment), .flatMap(populatedComment -> Mono.when(populateOwner(populatedComment),

View File

@ -26,6 +26,9 @@ public class ReplyRequest {
@Schema(defaultValue = "false") @Schema(defaultValue = "false")
private Boolean allowNotification; private Boolean allowNotification;
@Schema(defaultValue = "false")
private Boolean hidden;
private CommentEmailOwner owner; private CommentEmailOwner owner;
private String quoteReply; private String quoteReply;
@ -45,6 +48,7 @@ public class ReplyRequest {
spec.setRaw(raw); spec.setRaw(raw);
spec.setContent(content); spec.setContent(content);
spec.setAllowNotification(allowNotification); spec.setAllowNotification(allowNotification);
spec.setHidden(hidden);
spec.setQuoteReply(quoteReply); spec.setQuoteReply(quoteReply);
if (owner != null) { if (owner != null) {

View File

@ -64,7 +64,7 @@ public class ReplyServiceImpl extends AbstractCommentService implements ReplySer
.flatMap(this::approveComment) .flatMap(this::approveComment)
.filter(comment -> isTrue(comment.getSpec().getApproved())) .filter(comment -> isTrue(comment.getSpec().getApproved()))
.switchIfEmpty(Mono.error(requestRestrictedExceptionSupplier)) .switchIfEmpty(Mono.error(requestRestrictedExceptionSupplier))
.flatMap(comment -> prepareReply(commentName, reply)) .flatMap(comment -> prepareReply(comment, reply))
.flatMap(this::doCreateReply); .flatMap(this::doCreateReply);
} }
@ -76,6 +76,9 @@ public class ReplyServiceImpl extends AbstractCommentService implements ReplySer
return approveReply(quotedReply) return approveReply(quotedReply)
.filter(reply -> isTrue(reply.getSpec().getApproved())) .filter(reply -> isTrue(reply.getSpec().getApproved()))
.switchIfEmpty(Mono.error(requestRestrictedExceptionSupplier)) .switchIfEmpty(Mono.error(requestRestrictedExceptionSupplier))
.doOnNext(approvedQuoteReply -> prepared.getSpec()
.setHidden(approvedQuoteReply.getSpec().getHidden())
)
.flatMap(approvedQuoteReply -> client.create(prepared)); .flatMap(approvedQuoteReply -> client.create(prepared));
} }
@ -131,8 +134,9 @@ public class ReplyServiceImpl extends AbstractCommentService implements ReplySer
.filter(OptimisticLockingFailureException.class::isInstance)); .filter(OptimisticLockingFailureException.class::isInstance));
} }
private Mono<Reply> prepareReply(String commentName, Reply reply) { private Mono<Reply> prepareReply(Comment comment, Reply reply) {
reply.getSpec().setCommentName(commentName); reply.getSpec().setCommentName(comment.getMetadata().getName());
reply.getSpec().setHidden(comment.getSpec().getHidden());
if (reply.getSpec().getTop() == null) { if (reply.getSpec().getTop() == null) {
reply.getSpec().setTop(false); reply.getSpec().setTop(false);
} }

View File

@ -176,8 +176,6 @@ public class CommentFinderEndpoint implements CustomEndpoint {
Reply reply = replyRequest.toReply(); Reply reply = replyRequest.toReply();
reply.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request)); reply.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request));
reply.getSpec().setUserAgent(HaloUtils.userAgentFrom(request)); reply.getSpec().setUserAgent(HaloUtils.userAgentFrom(request));
// fix gh-2951
reply.getSpec().setHidden(false);
return environmentFetcher.fetchComment() return environmentFetcher.fetchComment()
.map(commentSetting -> { .map(commentSetting -> {
if (isFalse(commentSetting.getEnable())) { if (isFalse(commentSetting.getEnable())) {
@ -191,6 +189,11 @@ public class CommentFinderEndpoint implements CustomEndpoint {
} }
reply.getSpec() reply.getSpec()
.setApproved(isFalse(commentSetting.getRequireReviewForNew())); .setApproved(isFalse(commentSetting.getRequireReviewForNew()));
if (reply.getSpec().getHidden() == null) {
reply.getSpec().setHidden(false);
}
return reply; return reply;
}) })
.defaultIfEmpty(reply); .defaultIfEmpty(reply);

View File

@ -14,6 +14,8 @@ import java.util.Set;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.ReactiveTransactionManager; import org.springframework.transaction.ReactiveTransactionManager;
@ -50,6 +52,7 @@ import run.halo.app.infra.exception.EmailVerificationFailed;
import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; import run.halo.app.infra.exception.UnsatisfiedAttributeValueException;
import run.halo.app.infra.exception.UserNotFoundException; import run.halo.app.infra.exception.UserNotFoundException;
import run.halo.app.plugin.extensionpoint.ExtensionGetter; import run.halo.app.plugin.extensionpoint.ExtensionGetter;
import run.halo.app.security.authorization.AuthorityUtils;
import run.halo.app.security.device.DeviceService; import run.halo.app.security.device.DeviceService;
@Service @Service
@ -183,6 +186,15 @@ public class UserServiceImpl implements UserService {
}); });
} }
@Override
public Mono<Boolean> hasSufficientRoles(Collection<String> roles) {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(a -> AuthorityUtils.authoritiesToRoles(a.getAuthorities()))
.flatMap(userRoles -> roleService.contains(userRoles, roles))
.defaultIfEmpty(false);
}
@Override @Override
public Mono<User> signUp(SignUpData signUpData) { public Mono<User> signUp(SignUpData signUpData) {
return environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class) return environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class)

View File

@ -3,25 +3,28 @@ package run.halo.app.theme.finders.impl;
import static java.nio.charset.StandardCharsets.UTF_8; import static java.nio.charset.StandardCharsets.UTF_8;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import static run.halo.app.core.extension.content.Comment.CommentOwner.ownerIdentity;
import static run.halo.app.extension.index.query.QueryFactory.and; import static run.halo.app.extension.index.query.QueryFactory.and;
import static run.halo.app.extension.index.query.QueryFactory.equal; import static run.halo.app.extension.index.query.QueryFactory.equal;
import static run.halo.app.extension.index.query.QueryFactory.isNull; import static run.halo.app.extension.index.query.QueryFactory.isNull;
import static run.halo.app.extension.index.query.QueryFactory.or; import static run.halo.app.extension.index.query.QueryFactory.or;
import com.google.common.hash.Hashing; import com.google.common.hash.Hashing;
import java.security.Principal;
import java.util.HashMap; import java.util.HashMap;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.function.Function; import java.util.function.Function;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.lang.Nullable; import org.springframework.lang.Nullable;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContext;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.Assert; import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.content.comment.OwnerInfo; import run.halo.app.content.comment.OwnerInfo;
@ -32,14 +35,13 @@ import run.halo.app.core.extension.content.Comment;
import run.halo.app.core.extension.content.Reply; import run.halo.app.core.extension.content.Reply;
import run.halo.app.core.user.service.UserService; import run.halo.app.core.user.service.UserService;
import run.halo.app.extension.AbstractExtension; import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult; import run.halo.app.extension.ListResult;
import run.halo.app.extension.PageRequest; import run.halo.app.extension.PageRequest;
import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Ref; import run.halo.app.extension.Ref;
import run.halo.app.extension.index.query.Query;
import run.halo.app.extension.router.selector.FieldSelector;
import run.halo.app.infra.AnonymousUserConst; import run.halo.app.infra.AnonymousUserConst;
import run.halo.app.theme.finders.CommentPublicQueryService; import run.halo.app.theme.finders.CommentPublicQueryService;
import run.halo.app.theme.finders.vo.CommentStatsVo; import run.halo.app.theme.finders.vo.CommentStatsVo;
@ -59,6 +61,8 @@ import run.halo.app.theme.finders.vo.ReplyVo;
public class CommentPublicQueryServiceImpl implements CommentPublicQueryService { public class CommentPublicQueryServiceImpl implements CommentPublicQueryService {
private static final int DEFAULT_SIZE = 10; private static final int DEFAULT_SIZE = 10;
private static final String COMMENT_VIEW_PERMISSION = "role-template-view-comments";
private final ReactiveExtensionClient client; private final ReactiveExtensionClient client;
private final UserService userService; private final UserService userService;
private final CounterService counterService; private final CounterService counterService;
@ -80,22 +84,18 @@ public class CommentPublicQueryServiceImpl implements CommentPublicQueryService
var pageRequest = Optional.ofNullable(pageParam) var pageRequest = Optional.ofNullable(pageParam)
.map(page -> page.withSort(page.getSort().and(defaultCommentSort()))) .map(page -> page.withSort(page.getSort().and(defaultCommentSort())))
.orElse(PageRequestImpl.ofSize(0)); .orElse(PageRequestImpl.ofSize(0));
return fixedCommentFieldSelector(ref) return populateCommentListOptions(ref)
.flatMap(fieldSelector -> { .flatMap(listOptions -> client.listBy(Comment.class, listOptions, pageRequest))
var listOptions = new ListOptions(); .flatMap(listResult -> Flux.fromStream(listResult.get())
listOptions.setFieldSelector(fieldSelector); .map(this::toCommentVo)
return client.listBy(Comment.class, listOptions, pageRequest) .flatMapSequential(Function.identity())
.flatMap(listResult -> Flux.fromStream(listResult.get()) .collectList()
.map(this::toCommentVo) .map(commentVos -> new ListResult<>(listResult.getPage(),
.flatMapSequential(Function.identity()) listResult.getSize(),
.collectList() listResult.getTotal(),
.map(commentVos -> new ListResult<>(listResult.getPage(), commentVos)
listResult.getSize(), )
listResult.getTotal(), )
commentVos)
)
);
})
.defaultIfEmpty(ListResult.emptyResult()); .defaultIfEmpty(ListResult.emptyResult());
} }
@ -127,10 +127,10 @@ public class CommentPublicQueryServiceImpl implements CommentPublicQueryService
@Override @Override
public Mono<ListResult<ReplyVo>> listReply(String commentName, PageRequest pageParam) { public Mono<ListResult<ReplyVo>> listReply(String commentName, PageRequest pageParam) {
return fixedReplyFieldSelector(commentName) // check comment
.flatMap(fieldSelector -> { return client.get(Comment.class, commentName)
var listOptions = new ListOptions(); .flatMap(this::populateReplyListOptions)
listOptions.setFieldSelector(fieldSelector); .flatMap(listOptions -> {
var pageRequest = Optional.ofNullable(pageParam) var pageRequest = Optional.ofNullable(pageParam)
.map(page -> page.withSort(page.getSort().and(defaultReplySort()))) .map(page -> page.withSort(page.getSort().and(defaultReplySort())))
.orElse(PageRequestImpl.ofSize(0)); .orElse(PageRequestImpl.ofSize(0));
@ -250,53 +250,76 @@ public class CommentPublicQueryServiceImpl implements CommentPublicQueryService
.map(OwnerInfo::from); .map(OwnerInfo::from);
} }
private Mono<FieldSelector> fixedCommentFieldSelector(@Nullable Ref ref) { private Mono<ListOptions> populateCommentListOptions(@Nullable Ref ref) {
return Mono.fromSupplier( return populateVisibleListOptions(null)
() -> { .doOnNext(builder -> {
var baseQuery = isNull("metadata.deletionTimestamp"); if (ref != null) {
if (ref != null) { builder.andQuery(
baseQuery = equal("spec.subjectRef", Comment.toSubjectRefKey(ref)));
and(baseQuery, }
equal("spec.subjectRef", Comment.toSubjectRefKey(ref))); })
.map(ListOptions.ListOptionsBuilder::build);
}
private Mono<ListOptions.ListOptionsBuilder> populateVisibleListOptions(
@Nullable Comment comment) {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Authentication::getName)
.defaultIfEmpty(AnonymousUserConst.PRINCIPAL)
.zipWith(userService.hasSufficientRoles(Set.of(COMMENT_VIEW_PERMISSION))
.defaultIfEmpty(false))
.flatMap(tuple2 -> {
var username = tuple2.getT1();
var hasViewPermission = tuple2.getT2();
var commentHidden = false;
var isCommentOwner = false;
if (comment != null) {
commentHidden = Boolean.TRUE.equals(comment.getSpec().getHidden());
var owner = comment.getSpec().getOwner();
isCommentOwner = owner != null && Objects.equals(
ownerIdentity(owner.getKind(), owner.getName()),
ownerIdentity(User.KIND, username)
);
boolean hasPermission =
(!commentHidden) || (hasViewPermission || isCommentOwner);
if (ExtensionUtil.isDeleted(comment) || !hasPermission) {
return Mono.error(new ServerWebInputException(
"The comment was not found, hidden or deleted."
));
} }
return baseQuery; }
})
.flatMap(this::concatVisibleQuery) var builder = ListOptions.builder();
.map(FieldSelector::of); builder.andQuery(isNull("metadata.deletionTimestamp"));
var visibleQuery = and(
equal("spec.hidden", BooleanUtils.FALSE),
equal("spec.approved", BooleanUtils.TRUE)
);
var isAnonymous = AnonymousUserConst.isAnonymousUser(username);
if (isAnonymous) {
builder.andQuery(visibleQuery);
} else if (!(hasViewPermission || (commentHidden && isCommentOwner))) {
builder.andQuery(or(
equal("spec.owner", ownerIdentity(User.KIND, username)),
visibleQuery
));
}
// View all replies if the user is not an anonymous user, has view permission
// or is the comment owner.
return Mono.just(builder);
});
} }
private Mono<Query> concatVisibleQuery(Query query) { private Mono<ListOptions> populateReplyListOptions(Comment comment) {
Assert.notNull(query, "The query must not be null");
var approvedQuery = and(
equal("spec.approved", BooleanUtils.TRUE),
equal("spec.hidden", BooleanUtils.FALSE)
);
// we should list all comments that the user owns
return getCurrentUserWithoutAnonymous()
.map(username -> or(approvedQuery, equal("spec.owner",
Comment.CommentOwner.ownerIdentity(User.KIND, username)))
)
.defaultIfEmpty(approvedQuery)
.map(compositeQuery -> and(query, compositeQuery));
}
private Mono<FieldSelector> fixedReplyFieldSelector(String commentName) {
Assert.notNull(commentName, "The commentName must not be null");
// The comment name must be equal to the comment name of the reply // The comment name must be equal to the comment name of the reply
// is approved and not hidden // is approved and not hidden
return Mono.fromSupplier(() -> and( return populateVisibleListOptions(comment)
equal("spec.commentName", commentName), .doOnNext(builder ->
isNull("metadata.deletionTimestamp") builder.andQuery(equal("spec.commentName", comment.getMetadata().getName()))
)) )
.flatMap(this::concatVisibleQuery) .map(ListOptions.ListOptionsBuilder::build);
.map(FieldSelector::of);
}
Mono<String> getCurrentUserWithoutAnonymous() {
return ReactiveSecurityContextHolder.getContext()
.mapNotNull(SecurityContext::getAuthentication)
.map(Principal::getName)
.filter(username -> !AnonymousUserConst.PRINCIPAL.equals(username));
} }
static Sort defaultCommentSort() { static Sort defaultCommentSort() {

View File

@ -35,6 +35,7 @@ import run.halo.app.extension.SchemeManager;
import run.halo.app.extension.index.IndexerFactory; import run.halo.app.extension.index.IndexerFactory;
import run.halo.app.extension.store.ReactiveExtensionStoreClient; import run.halo.app.extension.store.ReactiveExtensionStoreClient;
import run.halo.app.infra.AnonymousUserConst; import run.halo.app.infra.AnonymousUserConst;
import run.halo.app.infra.exception.DuplicateNameException;
import run.halo.app.infra.utils.JsonUtils; import run.halo.app.infra.utils.JsonUtils;
@DirtiesContext @DirtiesContext
@ -288,11 +289,19 @@ class CommentPublicQueryServiceIntegrationTest {
@Nested @Nested
class ListReplyTest { class ListReplyTest {
private final List<Reply> storedReplies = mockRelies(); private final List<Reply> storedReplies = mockRelies();
@Autowired @Autowired
private CommentPublicQueryServiceImpl commentPublicQueryService; private CommentPublicQueryServiceImpl commentPublicQueryService;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// create comment
var comment = createComment();
client.create(comment)
.onErrorResume(DuplicateNameException.class, e -> Mono.just(comment))
.as(StepVerifier::create)
.expectNextCount(1)
.verifyComplete();
Flux.fromIterable(storedReplies) Flux.fromIterable(storedReplies)
.flatMap(reply -> client.create(reply)) .flatMap(reply -> client.create(reply))
.as(StepVerifier::create) .as(StepVerifier::create)

View File

@ -14,6 +14,7 @@ import {
VDescriptionItem, VDescriptionItem,
VModal, VModal,
VSpace, VSpace,
VTag,
} from "@halo-dev/components"; } from "@halo-dev/components";
import { useQueryClient } from "@tanstack/vue-query"; import { useQueryClient } from "@tanstack/vue-query";
import { useUserAgent } from "@uc/modules/profile/tabs/composables/use-user-agent"; import { useUserAgent } from "@uc/modules/profile/tabs/composables/use-user-agent";
@ -177,6 +178,11 @@ const { data: contentProvider } = useContentProviderExtensionPoint();
<VDescriptionItem <VDescriptionItem
:label="$t('core.comment.comment_detail_modal.fields.content')" :label="$t('core.comment.comment_detail_modal.fields.content')"
> >
<div v-if="comment.comment.spec.hidden" class="mb-2">
<VTag>
{{ $t("core.comment.list.fields.private") }}
</VTag>
</div>
<component <component
:is="contentProvider?.component" :is="contentProvider?.component"
:content="comment.comment.spec.content" :content="comment.comment.spec.content"

View File

@ -21,6 +21,7 @@ import {
VLoading, VLoading,
VSpace, VSpace,
VStatusDot, VStatusDot,
VTag,
} from "@halo-dev/components"; } from "@halo-dev/components";
import type { OperationItem } from "@halo-dev/console-shared"; import type { OperationItem } from "@halo-dev/console-shared";
import { useQuery, useQueryClient } from "@tanstack/vue-query"; import { useQuery, useQueryClient } from "@tanstack/vue-query";
@ -291,6 +292,9 @@ const { data: contentProvider } = useContentProviderExtensionPoint();
:owner="comment?.owner" :owner="comment?.owner"
@click="detailModalVisible = true" @click="detailModalVisible = true"
/> />
<VTag v-if="comment.comment.spec.hidden">
{{ $t("core.comment.list.fields.private") }}
</VTag>
<span class="whitespace-nowrap text-sm text-gray-900"> <span class="whitespace-nowrap text-sm text-gray-900">
{{ $t("core.comment.text.commented_on") }} {{ $t("core.comment.text.commented_on") }}
</span> </span>

View File

@ -15,6 +15,7 @@ import {
VDescriptionItem, VDescriptionItem,
VModal, VModal,
VSpace, VSpace,
VTag,
} from "@halo-dev/components"; } from "@halo-dev/components";
import { useQueryClient } from "@tanstack/vue-query"; import { useQueryClient } from "@tanstack/vue-query";
import { useUserAgent } from "@uc/modules/profile/tabs/composables/use-user-agent"; import { useUserAgent } from "@uc/modules/profile/tabs/composables/use-user-agent";
@ -185,17 +186,25 @@ const { data: contentProvider } = useContentProviderExtensionPoint();
<VDescriptionItem <VDescriptionItem
:label="$t('core.comment.reply_detail_modal.fields.original_comment')" :label="$t('core.comment.reply_detail_modal.fields.original_comment')"
> >
<OwnerButton :owner="comment.owner" /> <div class="mb-2 flex items-center gap-2">
<div class="mt-2"> <OwnerButton :owner="comment.owner" />
<component <VTag v-if="comment.comment.spec.hidden">
:is="contentProvider?.component" {{ $t("core.comment.list.fields.private") }}
:content="comment.comment.spec.content" </VTag>
/>
</div> </div>
<component
:is="contentProvider?.component"
:content="comment.comment.spec.content"
/>
</VDescriptionItem> </VDescriptionItem>
<VDescriptionItem <VDescriptionItem
:label="$t('core.comment.reply_detail_modal.fields.content')" :label="$t('core.comment.reply_detail_modal.fields.content')"
> >
<div v-if="reply.reply.spec.hidden" class="mb-2">
<VTag>
{{ $t("core.comment.list.fields.private") }}
</VTag>
</div>
<div> <div>
<span <span
v-if="quoteReply" v-if="quoteReply"

View File

@ -15,6 +15,7 @@ import {
VEntity, VEntity,
VEntityField, VEntityField,
VStatusDot, VStatusDot,
VTag,
} from "@halo-dev/components"; } from "@halo-dev/components";
import type { OperationItem } from "@halo-dev/console-shared"; import type { OperationItem } from "@halo-dev/console-shared";
import { useQueryClient } from "@tanstack/vue-query"; import { useQueryClient } from "@tanstack/vue-query";
@ -226,6 +227,9 @@ const { data: contentProvider } = useContentProviderExtensionPoint();
:owner="reply?.owner" :owner="reply?.owner"
@click="detailModalVisible = true" @click="detailModalVisible = true"
/> />
<VTag v-if="comment.comment.spec.hidden">
{{ $t("core.comment.list.fields.private") }}
</VTag>
<span class="whitespace-nowrap text-sm text-gray-900"> <span class="whitespace-nowrap text-sm text-gray-900">
{{ $t("core.comment.text.replied_below") }} {{ $t("core.comment.text.replied_below") }}
</span> </span>

View File

@ -520,7 +520,7 @@ export const PostV1alpha1ConsoleApiAxiosParamCreator = function (configuration?:
}; };
}, },
/** /**
* Publish a post. * UnPublish a post.
* @param {string} name * @param {string} name
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
@ -797,7 +797,7 @@ export const PostV1alpha1ConsoleApiFp = function(configuration?: Configuration)
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
}, },
/** /**
* Publish a post. * UnPublish a post.
* @param {string} name * @param {string} name
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
@ -935,7 +935,7 @@ export const PostV1alpha1ConsoleApiFactory = function (configuration?: Configura
return localVarFp.revertToSpecifiedSnapshotForPost(requestParameters.name, requestParameters.revertSnapshotForPostParam, options).then((request) => request(axios, basePath)); return localVarFp.revertToSpecifiedSnapshotForPost(requestParameters.name, requestParameters.revertSnapshotForPostParam, options).then((request) => request(axios, basePath));
}, },
/** /**
* Publish a post. * UnPublish a post.
* @param {PostV1alpha1ConsoleApiUnpublishPostRequest} requestParameters Request parameters. * @param {PostV1alpha1ConsoleApiUnpublishPostRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
@ -1362,7 +1362,7 @@ export class PostV1alpha1ConsoleApi extends BaseAPI {
} }
/** /**
* Publish a post. * UnPublish a post.
* @param {PostV1alpha1ConsoleApiUnpublishPostRequest} requestParameters Request parameters. * @param {PostV1alpha1ConsoleApiUnpublishPostRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}

View File

@ -38,6 +38,12 @@ export interface CommentRequest {
* @memberof CommentRequest * @memberof CommentRequest
*/ */
'content': string; 'content': string;
/**
*
* @type {boolean}
* @memberof CommentRequest
*/
'hidden'?: boolean;
/** /**
* *
* @type {CommentEmailOwner} * @type {CommentEmailOwner}

View File

@ -35,6 +35,12 @@ export interface ReplyRequest {
* @memberof ReplyRequest * @memberof ReplyRequest
*/ */
'content': string; 'content': string;
/**
*
* @type {boolean}
* @memberof ReplyRequest
*/
'hidden'?: boolean;
/** /**
* *
* @type {CommentEmailOwner} * @type {CommentEmailOwner}

View File

@ -197,6 +197,13 @@ core:
button: Reply and approve button: Reply and approve
cancel_approve: cancel_approve:
button: Cancel approve button: Cancel approve
list:
fields:
private: Private
reply_modal:
fields:
hidden:
label: Private reply
detail_modal: detail_modal:
fields: fields:
owner: Commentator owner: Commentator

View File

@ -595,6 +595,7 @@ core:
reply_count: "{count} Replies" reply_count: "{count} Replies"
has_new_replies: New replies has_new_replies: New replies
pending_review: Pending review pending_review: Pending review
private: Private
subject_refs: subject_refs:
post: Post post: Post
page: Page page: Page

View File

@ -561,6 +561,7 @@ core:
reply_count: "{count} 条回复" reply_count: "{count} 条回复"
has_new_replies: 有新的回复 has_new_replies: 有新的回复
pending_review: 待审核 pending_review: 待审核
private: 私密
subject_refs: subject_refs:
post: 文章 post: 文章
page: 页面 page: 页面

View File

@ -546,6 +546,7 @@ core:
reply_count: "{count} 條回覆" reply_count: "{count} 條回覆"
has_new_replies: 有新的回覆 has_new_replies: 有新的回覆
pending_review: 待審核 pending_review: 待審核
private: 私密
subject_refs: subject_refs:
post: 文章 post: 文章
page: 頁面 page: 頁面