This is an automated email from the ASF dual-hosted git repository. linkinstar pushed a commit to branch dev in repository https://gitbox.apache.org/repos/asf/incubator-answer.git
commit 283aa092fe09c692da4d3b79331e1785047af26c Author: hgaol <[email protected]> AuthorDate: Thu May 2 18:18:16 2024 +0800 update --- cmd/wire_gen.go | 29 +--- internal/base/reason/reason.go | 1 + internal/controller/controller.go | 1 + internal/controller/meta_controller.go | 61 +++++++++ internal/entity/meta_entity.go | 1 + internal/router/answer_api_router.go | 6 + .../index.scss => internal/schema/meta_schema.go | 19 +-- internal/service/activity/activity.go | 3 +- internal/service/meta/meta_service.go | 149 ++++++++++++++++++++- .../Questions/Detail/components/Answer/index.tsx | 3 + .../Questions/Detail/components/Question/index.tsx | 3 + .../Detail/components/Reactions/index.tsx | 86 ++++++++++++ ui/src/pages/Questions/Detail/index.scss | 44 +++++- 13 files changed, 364 insertions(+), 42 deletions(-) diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 9338e806..a1229717 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -1,28 +1,8 @@ -//go:build !wireinject -// +build !wireinject - -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - // Code generated by Wire. DO NOT EDIT. //go:generate go run github.com/google/wire/cmd/wire +//go:build !wireinject +// +build !wireinject package answercmd @@ -169,7 +149,7 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, collectionCommon := collectioncommon.NewCollectionCommon(collectionRepo) answerCommon := answercommon.NewAnswerCommon(answerRepo) metaRepo := meta.NewMetaRepo(dataData) - metaService := meta2.NewMetaService(metaRepo) + metaService := meta2.NewMetaService(metaRepo, userCommon) questionCommon := questioncommon.NewQuestionCommon(questionRepo, answerRepo, voteRepo, followRepo, tagCommonService, userCommon, collectionCommon, answerCommon, metaService, configService, activityQueueService, revisionRepo, dataData) userService := content.NewUserService(userRepo, userActiveActivityRepo, activityRepo, emailService, authService, siteInfoCommonService, userRoleRelService, userCommon, userExternalLoginService, userNotificationConfigRepo, userNotificationConfigService, questionCommon) captchaRepo := captcha.NewCaptchaRepo(dataData) @@ -250,7 +230,8 @@ func initApplication(debug bool, serverConf *conf.Server, dbConf *data.Database, permissionController := controller.NewPermissionController(rankService) userPluginController := controller.NewUserPluginController(pluginCommonService) reviewController := controller.NewReviewController(reviewService, rankService, captchaService) - answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, userAdminController, reasonController, themeController, siteInfoController, controllerSiteInfoController, notificationController, dashboardController, uploadController, activityController, roleController, pluginCon [...] + metaController := controller.NewMetaController(metaService) + answerAPIRouter := router.NewAnswerAPIRouter(langController, userController, commentController, reportController, voteController, tagController, followController, collectionController, questionController, answerController, searchController, revisionController, rankController, userAdminController, reasonController, themeController, siteInfoController, controllerSiteInfoController, notificationController, dashboardController, uploadController, activityController, roleController, pluginCon [...] swaggerRouter := router.NewSwaggerRouter(swaggerConf) uiRouter := router.NewUIRouter(controllerSiteInfoController, siteInfoCommonService) authUserMiddleware := middleware.NewAuthUserMiddleware(authService, siteInfoCommonService) diff --git a/internal/base/reason/reason.go b/internal/base/reason/reason.go index 0dbf73ac..cc30f71f 100644 --- a/internal/base/reason/reason.go +++ b/internal/base/reason/reason.go @@ -103,6 +103,7 @@ const ( AddBulkUsersFormatError = "error.user.add_bulk_users_format_error" AddBulkUsersAmountError = "error.user.add_bulk_users_amount_error" InvalidURLError = "error.common.invalid_url" + MetaObjectNotFound = "error.meta.object_not_found" ) // user external login reasons diff --git a/internal/controller/controller.go b/internal/controller/controller.go index c690d4c7..4db48322 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -49,4 +49,5 @@ var ProviderSetController = wire.NewSet( NewUserPluginController, NewReviewController, NewCaptchaController, + NewMetaController, ) diff --git a/internal/controller/meta_controller.go b/internal/controller/meta_controller.go new file mode 100644 index 00000000..de7d5ddc --- /dev/null +++ b/internal/controller/meta_controller.go @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package controller + +import ( + "github.com/apache/incubator-answer/internal/base/handler" + "github.com/apache/incubator-answer/internal/base/middleware" + "github.com/apache/incubator-answer/internal/schema" + "github.com/apache/incubator-answer/internal/service/meta" + "github.com/gin-gonic/gin" +) + +type MetaController struct { + metaService *meta.MetaService +} + +func NewMetaController( + metaService *meta.MetaService, +) *MetaController { + return &MetaController{ + metaService: metaService, + } +} + +// AddOrUpdateReaction add or update reaction +// @Summary add or update reaction +// @Description update reaction. if not exist, add one +// @Tags Meta +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param data body schema.ReactionReq true "reaction" +// @Success 200 {object} handler.RespBody +// @Router /answer/api/v1/meta/reaction [put] +func (mc *MetaController) AddOrUpdateReaction(ctx *gin.Context) { + req := &schema.ReactionReq{} + if handler.BindAndCheck(ctx, req) { + return + } + req.UserID = middleware.GetLoginUserIDFromContext(ctx) + + resp, err := mc.metaService.AddOrUpdateReaction(ctx, req) + handler.HandleResponse(ctx, err, resp) +} diff --git a/internal/entity/meta_entity.go b/internal/entity/meta_entity.go index df6d6536..203e83c5 100644 --- a/internal/entity/meta_entity.go +++ b/internal/entity/meta_entity.go @@ -26,6 +26,7 @@ const ( QuestionCloseReasonKey = "question.close.reason" AnswerEditSummaryKey = "answer.edit.summary" TagEditSummaryKey = "tag.edit.summary" + ObjectReactSummaryKey = "object.react.summary" ) // Meta meta diff --git a/internal/router/answer_api_router.go b/internal/router/answer_api_router.go index 40a5bba3..a23ddfbf 100644 --- a/internal/router/answer_api_router.go +++ b/internal/router/answer_api_router.go @@ -54,6 +54,7 @@ type AnswerAPIRouter struct { permissionController *controller.PermissionController userPluginController *controller.UserPluginController reviewController *controller.ReviewController + metaController *controller.MetaController } func NewAnswerAPIRouter( @@ -84,6 +85,7 @@ func NewAnswerAPIRouter( permissionController *controller.PermissionController, userPluginController *controller.UserPluginController, reviewController *controller.ReviewController, + metaController *controller.MetaController, ) *AnswerAPIRouter { return &AnswerAPIRouter{ langController: langController, @@ -113,6 +115,7 @@ func NewAnswerAPIRouter( permissionController: permissionController, userPluginController: userPluginController, reviewController: reviewController, + metaController: metaController, } } @@ -285,6 +288,9 @@ func (a *AnswerAPIRouter) RegisterAnswerAPIRouter(r *gin.RouterGroup) { r.GET("/user/plugin/configs", a.userPluginController.GetUserPluginList) r.GET("/user/plugin/config", a.userPluginController.GetUserPluginConfig) r.PUT("/user/plugin/config", a.userPluginController.UpdatePluginUserConfig) + + // meta + r.PUT("/meta/reaction", a.metaController.AddOrUpdateReaction) } func (a *AnswerAPIRouter) RegisterAnswerAdminAPIRouter(r *gin.RouterGroup) { diff --git a/ui/src/pages/Questions/Detail/index.scss b/internal/schema/meta_schema.go similarity index 67% copy from ui/src/pages/Questions/Detail/index.scss copy to internal/schema/meta_schema.go index b2327d79..55fc66e7 100644 --- a/ui/src/pages/Questions/Detail/index.scss +++ b/internal/schema/meta_schema.go @@ -17,14 +17,15 @@ * under the License. */ -.answer-item { - border-top: 1px solid var(--an-answer-item-border-top); -} +package schema -@media screen and (max-width: 768px) { - .questionDetailPage { - h1.h3 { - font-size: calc(1.275rem + .3vw)!important; - } - } +type ReactionReq struct { + ObjectID string `validate:"required" form:"object_id" json:"object_id"` // object id + Emoji string `validate:"required" form:"emoji" json:"emoji"` // emoji + Type string `validate:"required" form:"type" json:"type"` // type + UserID string `json:"-"` } + +type ReactSummaryMeta map[string][]string + +type ReactionResp ReactSummaryMeta diff --git a/internal/service/activity/activity.go b/internal/service/activity/activity.go index 0f3f8393..f6d692f5 100644 --- a/internal/service/activity/activity.go +++ b/internal/service/activity/activity.go @@ -23,9 +23,10 @@ import ( "context" "encoding/json" "fmt" - "github.com/apache/incubator-answer/internal/service/activity_common" "strings" + "github.com/apache/incubator-answer/internal/service/activity_common" + "github.com/apache/incubator-answer/internal/base/constant" "github.com/apache/incubator-answer/internal/base/handler" "github.com/apache/incubator-answer/internal/entity" diff --git a/internal/service/meta/meta_service.go b/internal/service/meta/meta_service.go index 3c229858..24e852cb 100644 --- a/internal/service/meta/meta_service.go +++ b/internal/service/meta/meta_service.go @@ -21,10 +21,14 @@ package meta import ( "context" + "encoding/json" + "errors" "github.com/apache/incubator-answer/internal/base/reason" "github.com/apache/incubator-answer/internal/entity" - "github.com/segmentfault/pacman/errors" + "github.com/apache/incubator-answer/internal/schema" + usercommon "github.com/apache/incubator-answer/internal/service/user_common" + myErrors "github.com/segmentfault/pacman/errors" ) // MetaRepo meta repository @@ -38,12 +42,14 @@ type MetaRepo interface { // MetaService user service type MetaService struct { - metaRepo MetaRepo + metaRepo MetaRepo + userCommon *usercommon.UserCommon } -func NewMetaService(metaRepo MetaRepo) *MetaService { +func NewMetaService(metaRepo MetaRepo, userCommon *usercommon.UserCommon) *MetaService { return &MetaService{ - metaRepo: metaRepo, + metaRepo: metaRepo, + userCommon: userCommon, } } @@ -79,7 +85,7 @@ func (ms *MetaService) GetMetaByObjectIdAndKey(ctx context.Context, objectID, ke return } if !exist { - return nil, errors.BadRequest(reason.UnknownError) + return nil, myErrors.BadRequest(reason.MetaObjectNotFound) } return meta, nil } @@ -92,3 +98,136 @@ func (ms *MetaService) GetMetaList(ctx context.Context, objID string) (metas []* } return metas, err } + +// GetReactionByObjectId get reaction +func (ms *MetaService) GetReactionByObjectId(ctx context.Context, objectID string) (resp schema.ReactionResp, err error) { + reactionMeta, err := ms.GetMetaByObjectIdAndKey(ctx, objectID, entity.ObjectReactSummaryKey) + + // if not exist, return nil + if err != nil { + var pacmanErr *myErrors.Error + if errors.As(err, &pacmanErr) && pacmanErr.Reason == reason.MetaObjectNotFound { + return nil, nil + } else { + return nil, err + } + } + + var reaction schema.ReactSummaryMeta + err = json.Unmarshal([]byte(reactionMeta.Value), &reaction) + if err != nil { + return nil, err + } + return ms.convertToReactionResp(ctx, reaction) +} + +// AddOrUpdateReaction add or update reaction +func (ms *MetaService) AddOrUpdateReaction(ctx context.Context, req *schema.ReactionReq) (resp schema.ReactionResp, err error) { + // get reaction for this object + reactionMeta, err := ms.GetMetaByObjectIdAndKey(ctx, req.ObjectID, entity.ObjectReactSummaryKey) + + var reaction schema.ReactSummaryMeta + if err != nil { + var pacmanErr *myErrors.Error + if errors.As(err, &pacmanErr) && pacmanErr.Reason == reason.MetaObjectNotFound { + // create new reaction summary + reaction = schema.ReactSummaryMeta{} + } else { + return nil, err + } + } else { + // json unmarshal reactionMeta.Value to reaction + err = json.Unmarshal([]byte(reactionMeta.Value), &reaction) + if err != nil { + return nil, err + } + } + + // update reaction + ms.updateReaction(req, reaction) + + // write back to meta repo + reactSumBytes, err := json.Marshal(reaction) + if err != nil { + return nil, err + } + + if reactionMeta == nil { + err = ms.AddMeta(ctx, req.ObjectID, entity.ObjectReactSummaryKey, string(reactSumBytes)) + } else { + err = ms.UpdateMeta(ctx, reactionMeta.ID, entity.ObjectReactSummaryKey, string(reactSumBytes)) + } + + if err != nil { + return nil, err + } + + resp, err = ms.convertToReactionResp(ctx, reaction) + if err != nil { + return nil, err + } + + return resp, nil +} + +// updateReaction update reaction +func (ms *MetaService) updateReaction(req *schema.ReactionReq, reaction schema.ReactSummaryMeta) { + emojiUserIds, ok := reaction[req.Emoji] + + if !ok { + emojiUserIds = make([]string, 0) + } + + found := false + for _, item := range emojiUserIds { + if item == req.UserID { + found = true + break + } + } + + removeItem := func(arr []string, target string) []string { + result := make([]string, 0, len(arr)) + + for _, item := range arr { + if item != target { + result = append(result, item) + } + } + + return result + } + + if req.Type == "activate" && !found { + emojiUserIds = append(emojiUserIds, req.UserID) + } else if req.Type == "deactivate" && found { + emojiUserIds = removeItem(emojiUserIds, req.UserID) + } else if req.Type == "toggle" { + if found { + emojiUserIds = removeItem(emojiUserIds, req.UserID) + } else { + emojiUserIds = append(emojiUserIds, req.UserID) + } + } + + reaction[req.Emoji] = emojiUserIds +} + +func (ms *MetaService) convertToReactionResp(ctx context.Context, reaction schema.ReactSummaryMeta) (schema.ReactionResp, error) { + resp := schema.ReactionResp{} + // traverse map and convert to user name + for emoji, userIds := range reaction { + userNames := make([]string, 0) + userBasicInfos, err := ms.userCommon.BatchUserBasicInfoByID(ctx, userIds) + if err != nil { + return nil, err + } + // get user name + for _, userBasicInfo := range userBasicInfos { + userNames = append(userNames, userBasicInfo.Username) + } + resp[emoji] = userNames + } + + return resp, nil +} diff --git a/ui/src/pages/Questions/Detail/components/Answer/index.tsx b/ui/src/pages/Questions/Detail/components/Answer/index.tsx index 474c83d6..6b382dba 100644 --- a/ui/src/pages/Questions/Detail/components/Answer/index.tsx +++ b/ui/src/pages/Questions/Detail/components/Answer/index.tsx @@ -36,6 +36,7 @@ import { scrollToElementTop, bgFadeOut } from '@/utils'; import { AnswerItem } from '@/common/interface'; import { acceptanceAnswer } from '@/services'; import { useRenderHtmlPlugin } from '@/utils/pluginKit'; +import Reactions from '../Reactions'; interface Props { data: AnswerItem; @@ -198,6 +199,8 @@ const Index: FC<Props> = ({ </div> </div> + <Reactions /> + <Comment objectId={data.id} mode="answer" diff --git a/ui/src/pages/Questions/Detail/components/Question/index.tsx b/ui/src/pages/Questions/Detail/components/Question/index.tsx index 0a76c2f9..1252098a 100644 --- a/ui/src/pages/Questions/Detail/components/Question/index.tsx +++ b/ui/src/pages/Questions/Detail/components/Question/index.tsx @@ -37,6 +37,7 @@ import { useRenderHtmlPlugin } from '@/utils/pluginKit'; import { formatCount, guard } from '@/utils'; import { following } from '@/services'; import { pathFactory } from '@/router/pathFactory'; +import Reactions from '../Reactions'; interface Props { data: any; @@ -212,6 +213,8 @@ const Index: FC<Props> = ({ data, initPage, hasAnswer, isLogged }) => { </div> </div> + <Reactions /> + <Comment objectId={data?.id} mode="question" diff --git a/ui/src/pages/Questions/Detail/components/Reactions/index.tsx b/ui/src/pages/Questions/Detail/components/Reactions/index.tsx new file mode 100644 index 00000000..6236c684 --- /dev/null +++ b/ui/src/pages/Questions/Detail/components/Reactions/index.tsx @@ -0,0 +1,86 @@ +import { FC, memo } from 'react'; +import { Button, OverlayTrigger, Tooltip } from 'react-bootstrap'; + +import { Icon } from '@/components'; + +interface Props {} + +interface Data { + title: string; + icon: string; + className: string; +} + +const data: Data[] = [ + { title: 'hello', icon: 'heart-fill', className: 'text-danger' }, + { title: 'hello', icon: 'emoji-laughing-fill', className: 'text-warning' }, + { title: 'hello', icon: 'emoji-frown-fill', className: 'text-warning' }, +]; + +const Index: FC<Props> = () => { + const renderTooltip = (props) => ( + <Tooltip id="reaction-button-tooltip" {...props} bsPrefix="tooltip"> + <div className="d-block d-md-flex flex-wrap m-0 p-0"> + {data.map((d) => ( + <Button + key={d.icon} + variant="light" + size="sm" + onClick={() => alert('hellob')}> + <Icon name={d.icon} className={d.className} /> + </Button> + ))} + </div> + </Tooltip> + ); + + return ( + <div className="d-block d-md-flex flex-wrap mt-4 mb-3"> + <Button + variant="outline-secondary" + className="rounded-pill answer-reaction-btn" + size="sm"> + <Icon name="chat-text-fill" /> + <span className="ms-1">{6} comments</span> + </Button> + + <OverlayTrigger trigger="click" placement="top" overlay={renderTooltip}> + <Button + variant="outline-secondary" + size="sm" + className="rounded-pill ms-2 answer-reaction-btn"> + <Icon name="emoji-smile-fill" /> + <span className="ms-1">+</span> + </Button> + </OverlayTrigger> + + {/* <div className="arrow" /> */} + + {data.map((d) => ( + <OverlayTrigger + placement="top" + // trigger="hover" + trigger="click" + overlay={ + <Tooltip> + <div className="text-start"> + <b>heart</b> <br /> fenbox, joyqi, robin, andrus, jackathon and + 7 more... + </div> + </Tooltip> + }> + <Button + title="hahah" + variant="outline-secondary" + className="rounded-pill ms-2 answer-reaction-btn" + size="sm"> + <Icon name={d.icon} className={d.className} /> + <span className="ms-1">{3}</span> + </Button> + </OverlayTrigger> + ))} + </div> + ); +}; + +export default memo(Index); diff --git a/ui/src/pages/Questions/Detail/index.scss b/ui/src/pages/Questions/Detail/index.scss index b2327d79..cf5e910d 100644 --- a/ui/src/pages/Questions/Detail/index.scss +++ b/ui/src/pages/Questions/Detail/index.scss @@ -16,15 +16,53 @@ * specific language governing permissions and limitations * under the License. */ +@import 'bootstrap/scss/functions'; +@import 'bootstrap/scss/variables'; -.answer-item { - border-top: 1px solid var(--an-answer-item-border-top); +.answer-reaction-btn { + padding-top: 0.2rem; + padding-bottom: 0.2rem; + border: 1px solid $gray-300; +} + +#reaction-button-tooltip { + .tooltip-inner { + padding: 0.2rem; + background-color: $white; + border: 1px solid $gray-300; + .btn { + background-color: $white; + border: none; + &:hover { + background-color: $gray-400; + } + } + } + + .tooltip-arrow { + overflow: hidden; + bottom: calc(-1 * $tooltip-arrow-height + 1px); + // width: calc($tooltip-arrow-width + 4px); + // height: calc($tooltip-arrow-height + 2px); + // box-sizing: content-box; + &::before { + border: 1px solid $gray-300; + width: 50%; + height: 100%; + transform: translate(-50%, -50%) rotate(-45deg); + // transform: rotate(-45deg); + box-sizing: content-box; + left: 50%; + bottom: 50%; + background-color: white; + } + } } @media screen and (max-width: 768px) { .questionDetailPage { h1.h3 { - font-size: calc(1.275rem + .3vw)!important; + font-size: calc(1.275rem + 0.3vw) !important; } } }
