core&ui: support reaction

This commit is contained in:
undefined committed 2022-02-07 01:59:19 +08:00
1 parent 48565ce711
commit 913658d144
12 files changed
+180 -37

No files matched your search

+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hydrooj",
"version": "3.4.6",
"version": "3.4.7",
"bin": "bin/hydrooj.js",
"main": "src/loader",
"module": "src/loader",
@@ -20,6 +20,8 @@
"cac": "^6.7.12",
"cookies": "^0.8.0",
"detect-browser": "^5.3.0",
"emoji-regex": "^10.0.0",
"emojis-list": "2.1.0",
"esbuild": "0.14.3",
"fs-extra": "^10.0.0",
"graphql": "^16.3.0",
+11 -4
View File
@@ -179,6 +179,11 @@ class DiscussionDetailHandler extends DiscussionHandler {
for (const drdoc of drdocs) {
if (drdoc.reply) uids.push(...drdoc.reply.map((drrdoc) => drrdoc.owner));
}
const reactions = { [did.toHexString()]: dsdoc?.react || {} };
await Promise.all(drdocs.map((drdoc) =>
discussion.getReaction(domainId, document.TYPE_DISCUSSION_REPLY, drdoc._id, this.user._id).then((reaction) => {
reactions[drdoc._id.toHexString()] = reaction;
})));
const udict = await user.getList(domainId, uids);
if (!dsdoc?.view) {
await Promise.all([
@@ -194,7 +199,7 @@ class DiscussionDetailHandler extends DiscussionHandler {
];
this.response.template = 'discussion_detail.html';
this.response.body = {
path, ddoc: this.ddoc, dsdoc, drdocs, page, pcount, drcount, udict, vnode: this.vnode,
path, ddoc: this.ddoc, dsdoc, drdocs, page, pcount, drcount, udict, vnode: this.vnode, reactions,
};
}
@@ -203,12 +208,14 @@ class DiscussionDetailHandler extends DiscussionHandler {
}
@param('type', Types.Range(['did', 'drid']))
@param('did', Types.ObjectID)
@param('id', Types.Name)
@param('id', Types.ObjectID)
@param('emoji', Types.Emoji)
@param('reverse', Types.Boolean)
async postReaction(domainId: string, type: string, did: ObjectID, id: string, reverse = false) {
this.checkPerm(PERM.PERM_ADD_REACTION);
await discussion.react(domainId, type === 'did' ? document.TYPE_DISCUSSION : document.TYPE_DISCUSSION_REPLY, did, id, this.user._id, reverse);
const docType = type === 'did' ? document.TYPE_DISCUSSION : document.TYPE_DISCUSSION_REPLY;
const [doc, sdoc] = await discussion.react(domainId, docType, did, id, this.user._id, reverse);
this.response.body = { doc, sdoc };
this.back();
}
+12 -3
View File
@@ -56,7 +56,7 @@ export const PROJECTION_LIST: DiscussionDoc.Field[] = [
'parentId', 'parentType', 'title',
];
export const PROJECTION_PUBLIC: DiscussionDoc.Field[] = [
...PROJECTION_LIST, 'content', 'history',
...PROJECTION_LIST, 'content', 'history', 'react', 'maintainer',
];
export const typeDisplay = {
@@ -184,8 +184,16 @@ export function getListReply(domainId: string, did: ObjectID): Promise<Discussio
}
export async function react(domainId: string, docType: keyof document.DocType, did: ObjectID, id: string, uid: number, reverse = false) {
const updated = await document.setIfNotStatus(domainId, docType, did, uid, `react.${id}`, reverse ? 0 : 1, reverse ? 0 : 1, {});
if (updated) await document.inc(domainId, docType, did, `react.${id}`, reverse ? -1 : 1);
let doc;
const sdoc = await document.setIfNotStatus(domainId, docType, did, uid, `react.${id}`, reverse ? 0 : 1, reverse ? 0 : 1, {});
if (sdoc) doc = await document.inc(domainId, docType, did, `react.${id}`, reverse ? -1 : 1);
else doc = await document.get(domainId, docType, did, ['react']);
return [doc, sdoc];
}
export async function getReaction(domainId: string, docType: keyof document.DocType, did: ObjectID, uid: number) {
const doc = await document.getStatus(domainId, docType, did, uid);
return doc?.react || {};
}
export async function addTailReply(
@@ -357,6 +365,7 @@ global.Hydro.model.discussion = {
editTailReply,
delTailReply,
react,
getReaction,
setStar,
getStatus,
setStatus,
+1 -1
View File
@@ -292,7 +292,7 @@ export class ProblemModel {
const q: any = { docId: { $in: pids } };
let pdocs = await document.getMulti(domainId, document.TYPE_PROBLEM, q)
.project(buildProjection(projection)).toArray();
if (group.length > 0) {
if (group.length > 0 && canViewHidden !== true) {
pdocs = pdocs.filter((i) => !i.assign?.length || Set.intersection(group, i.assign).size);
}
if (canViewHidden !== true) {
+6
View File
@@ -4,6 +4,7 @@ import http from 'http';
import { PassThrough } from 'stream';
import cac from 'cac';
import Cookies from 'cookies';
import emojiRegex from 'emoji-regex';
import Koa, { Context } from 'koa';
import Body from 'koa-body';
import Compress from 'koa-compress';
@@ -86,6 +87,7 @@ export interface Types {
NumericArray: Type,
CommaSeperatedArray: Type,
Set: Type,
Emoji: Type,
}
export const Types: Types = {
@@ -172,6 +174,10 @@ export const Types: Types = {
if (v instanceof Array) return new Set(v);
return v ? new Set([v]) : new Set();
}, null],
Emoji: [
(v: string) => v.matchAll(emojiRegex()).next().value[0],
(v) => emojiRegex().test(v),
],
};
function _buildParam(name: string, source: 'get' | 'post' | 'all' | 'route', ...args: Array<Type | boolean | Validator | Converter>) {
@@ -121,6 +121,7 @@ env.addGlobal('static_url', (assetName) => {
// eslint-disable-next-line no-eval
env.addGlobal('eval', eval);
env.addGlobal('Date', Date);
env.addGlobal('Object', Object);
env.addGlobal('Math', Math);
env.addGlobal('process', process);
env.addGlobal('global', global);
@@ -27,6 +27,37 @@ $downvote-color = red
&:first-child
border-top: 0
.reactions
display: flex
flex-direction: row
width: 100%
flex-wrap: wrap
margin-top: 0.25rem
overflow: visible
.reaction
display: flex
flex-direction: row
height: 1.75rem
white-space: nowrap
width: auto
margin: 0.125rem
padding: 0 0.5rem
border: 2px solid transparent
border-radius: 1.75rem
font-weight: 500
overflow: visible
border-color: lighten(blue, 75%)
&.active
background-color: lighten(blue, 60%)
&:hover
background-color: lighten(blue, 75%) !important
p
padding-top: 0px
margin-top: 0px
padding-left: 0px
margin-right: 2px
line-height: 1.5
.dczcomments__reply
>.media
@@ -1,5 +1,8 @@
import 'jquery.easing';
import * as React from 'react';
import ReactDOM from 'react-dom';
import { Popover } from '@blueprintjs/core';
import { AutoloadPage } from 'vj/misc/Page';
import CommentBox from 'vj/components/discussion/CommentBox';
import { ConfirmDialog } from 'vj/components/dialog';
@@ -9,6 +12,7 @@ import { slideDown, slideUp } from 'vj/utils/slide';
import request from 'vj/utils/request';
import i18n from 'vj/utils/i18n';
import tpl from 'vj/utils/tpl';
import { chunk } from 'lodash';
const $replyTemplate = $('.commentbox-container').eq(0).clone();
@@ -72,7 +76,7 @@ function onClickDummyBox(ev) {
.focus();
}
async function onCommentClickReplyComment(ev, options = {}) {
async function onCommentClickReplyComment(ev, options: any = {}) {
const $evTarget = $(ev.currentTarget);
if (CommentBox.get($evTarget)) {
@@ -184,14 +188,85 @@ function onCommentClickDeleteReply(ev) {
onCommentClickDelete('reply', ev);
}
function renderReactions(reactions, self, rootEle) {
let html = '';
for (const key in reactions) {
if (!reactions[key]) continue;
html += `<div class="reaction${self[key] ? ' active' : ''}"><p>${key} ${reactions[key]}</p></div>`;
}
rootEle.html(html);
}
async function handleEmojiClick(payload, emoji, ele) {
const res = await request.post('', { ...payload, emoji });
renderReactions(res.doc?.react, res.sdoc?.react, ele);
}
function getRow(count) {
if (count <= 2) return 2;
if (count <= 3) return 3;
if (count <= 4) return 4;
if (count <= 6) return 6;
return 12;
}
function Reaction({ payload, ele }) {
const emojiList: string[] = (UiContext.emojiList || '👍 👎 😄 😕 ❤️ 🤔 🤣 🌿 🍋 🕊️ 👀 🤣').split(' ');
const elesPerRow = getRow(Math.sqrt(emojiList.length));
const [focus, updateFocus] = React.useState(false);
const [finish, updateFinish] = React.useState(false);
if (finish) setTimeout(() => updateFinish(false), 1000);
return (
// eslint-disable-next-line no-nested-ternary
<Popover usePortal interactionKind="hover" isOpen={finish ? false : (focus ? true : undefined)}>
<span className="icon icon-emoji"></span>
<div>
{chunk(emojiList, elesPerRow).map((line, i) => (
<div className="row" key={+i} style={{ paddingBottom: 4, paddingTop: 4 }}>
{line.map((emoji) => (
<div
key={emoji}
className={`medium-${12 / elesPerRow} columns`}
onClick={() => handleEmojiClick(payload, emoji, ele).then(() => updateFinish(true))}
>
{emoji}
</div>
))}
</div>
))}
<div className="row" style={{ paddingTop: 7, paddingBottom: 4 }}>
<div className="medium-12 columns">
<input name="emojiSuggest" onFocus={() => updateFocus(true)} onBlur={() => updateFocus(false)}></input>
</div>
</div>
</div>
</Popover>
);
}
const commentsPage = new AutoloadPage('commentsPage', () => {
$(document).on('click', '[name="dczcomments__dummy-box"]', onClickDummyBox);
$(document).on('click', '[name="dczcomments__op-reply-comment"]', onCommentClickReplyComment);
$(document).on('click', '[name="dczcomments__op-reply-reply"]', onCommentClickReplyReply);
$(document).on('click', '[name="dczcomments__op-edit-comment"]', onCommentClickEditComment);
$(document).on('click', '[name="dczcomments__op-edit-reply"]', onCommentClickEditReply);
$(document).on('click', '[name="dczcomments__op-delete-comment"]', onCommentClickDeleteComment);
$(document).on('click', '[name="dczcomments__op-delete-reply"]', onCommentClickDeleteReply);
$(document).on('click', '[data-op="reply"][data-type="comment"]', onCommentClickReplyComment);
$(document).on('click', '[data-op="reply"][data-type="reply"]', onCommentClickReplyReply);
$(document).on('click', '[data-op="edit"][data-type="comment"]', onCommentClickEditComment);
$(document).on('click', '[data-op="edit"][data-type="reply"]', onCommentClickEditReply);
$(document).on('click', '[data-op="delete"][data-type="comment"]', onCommentClickDeleteComment);
$(document).on('click', '[data-op="delete"][data-type="reply"]', onCommentClickDeleteReply);
$('[data-op="react"]').each((i, e) => {
ReactDOM.render(<Reaction payload={$(e).data('form')} ele={$(e).closest('.media__body').find('.reactions')} />, e);
});
$(document).on('click', '.reaction', async (e) => {
const target = $(e.currentTarget);
const res = await request.post('', {
operation: 'reaction',
type: 'drid',
id: target.parent().parent().data('drid'),
emoji: target.text().trim().split(' ')[0],
reverse: target.hasClass('active'),
});
renderReactions(res.doc?.react, res.sdoc?.react, target.parent());
});
});
export default commentsPage;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><path d="M475 0c262.34 0 475 212.66 475 475S737.34 950 475 950 0 737.34 0 475 212.66 0 475 0zm0 95C265.13 95 95 265.13 95 475s170.13 380 380 380 380-170.13 380-380S684.87 95 475 95zM285 475l.71 10.22C295.38 589.93 376.43 665 475 665c101.75 0 184.83-80 189.77-180.52L665 475h95l-.6 11.31C748.34 643.74 625.25 760 475 760c-153.82 0-279.18-121.86-284.8-274.32L190 475zm71.25-190A71.25 71.25 0 11285 356.25 71.25 71.25 0 01356.25 285zm237.5 0a71.25 71.25 0 11-71.25 71.25A71.25 71.25 0 01593.75 285z"/></svg>

After

Width:  |  Height:  |  Size: 568 B

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hydrooj/ui-default",
"version": "4.31.3",
"version": "4.31.4",
"author": "undefined <i@undefined.moe>",
"license": "AGPL-3.0",
"main": "hydro.js",
@@ -1,5 +1,14 @@
{% import "components/user.html" as user with context %}
{% macro action(tooltip, operation, type, data, icon) %}
<a href="javascript:;"
{% if tooltip %} data-tooltip="{{ tooltip }}"{% endif %}
data-op="{{ operation }}" data-type="{{ type }}"
{% if data %} data-form="{{ data|json }}"{% endif %}>
<span class="icon icon-{{ icon }}"></span>
</a>
{% endmacro %}
{% macro render(
view, docs, udict,
comment_ref,
@@ -24,7 +33,8 @@
reply_edit_op = 'edit_tail_reply',
reply_edit_text = 'Update',
comment_delete_op = 'delete_reply',
reply_delete_op = 'delete_tail_reply'
reply_delete_op = 'delete_tail_reply',
reactions = {}
) %}
<ul class="dczcomments__list view--discussion">
{% if handler.user.hasPerm(comment_post_perm) %}
@@ -82,30 +92,34 @@
</div>
<div class="dczcomments__operations nojs--hide">
{% if handler.user.hasPerm(reply_post_perm) %}
<a href="javascript:;" data-tooltip="{{ _('Reply') }}" name="dczcomments__op-reply-comment"
data-form="{{ {operation: reply_post_op, drid: doc._id}|json }}"
><span class="icon icon-reply"></span></a>
{{ action(_('Reply'), 'reply', 'comment', {operation: reply_post_op, drid: doc._id}, 'reply') }}
{% endif %}
{% if (handler.user.hasPerm(comment_edit_self_perm) and handler.user.own(doc)) or handler.user.hasPerm(comment_edit_perm) %}
<a href="javascript:;" data-tooltip="{{ _('Edit') }}" name="dczcomments__op-edit-comment"
data-form="{{ {operation: comment_edit_op, drid: doc._id}|json }}"
><span class="icon icon-edit"></span></a>
{{ action(_('Edit'), 'edit', 'comment', {operation: comment_edit_op, drid: doc._id}, 'edit') }}
{% endif %}
{% if (handler.user.hasPerm(comment_delete_self_perm) and handler.user.own(doc)) or handler.user.hasPerm(comment_delete_perm) %}
<a href="javascript:;" data-tooltip="{{ _('Delete') }}" name="dczcomments__op-delete-comment"
data-form="{{ {operation: comment_delete_op, drid: doc._id}|json }}"
><span class="icon icon-delete"></span></a>
{{ action(_('Delete'), 'delete', 'comment', {operation: comment_delete_op, drid: doc._id}, 'delete') }}
{% endif %}
{% if (handler.user.hasPerm(perm.PERM_ADD_REACTION)) %}
{{ action('', 'react', 'comment', {operation: 'reaction', type:'drid', id: doc._id}, 'emoji') }}
{% endif %}
</div>
</div>
<div class="typo" data-emoji-enabled data-raw-url="{{ url('discussion_reply_raw', did=doc.parentId, drid=doc._id) }}">
<div class="typo" data-emoji-enabled data-drid="{{ doc.docId }}" data-raw-url="{{ url('discussion_reply_raw', did=doc.parentId, drid=doc._id) }}">
{{ doc['content']|markdown|safe }}
<div class="reactions row">
{% for key in Object.keys(doc.react or {}) %}
{% if doc.react[key] %}
<div class="reaction{% if reactions[doc.docId][key] %} active{% endif %}"><p>{{ key }} {{ doc.react[key] }}</p></div>
{% endif %}
{% endfor %}
</div>
</div>
<div class="commentbox-edit-target"></div>
<ul class="dczcomments__replies commentbox-reply-target">
{%- for rdoc in doc['reply'] -%}
{% set udoc = udict[rdoc.owner] %}
<li class="dczcomments__reply{% if mode_create %} commentbox-container{% endif %}">
<li class="dczcomments__reply">
<div class="media">
<div class="media__left top">
<img src="{{ avatarUrl(udoc.avatar, 50) }}" width="50" height="50" class="medium user-profile-avatar">
@@ -118,17 +132,13 @@
</div>
<div class="dczcomments__operations nojs--hide">
{% if handler.user.hasPerm(reply_post_perm) %}
<a href="javascript:;" data-tooltip="{{ _('Reply') }}" name="dczcomments__op-reply-reply"><span class="icon icon-reply"></span></a>
{{ action(_('Reply'), 'reply', 'reply', {}, 'reply') }}
{% endif %}
{% if handler.user.own(doc) or handler.user.hasPerm(reply_edit_perm) %}
<a href="javascript:;" data-tooltip="{{ _('Edit') }}" name="dczcomments__op-edit-reply"
data-form="{{ {operation: reply_edit_op, drid: doc._id, drrid: rdoc._id}|json }}"
><span class="icon icon-edit"></span></a>
{{ action(_('Edit'), 'edit', 'reply', {operation: reply_edit_op, drid: doc._id, drrid: rdoc._id}, 'edit') }}
{% endif %}
{% if handler.user.own(doc) or handler.user.hasPerm(reply_delete_perm) %}
<a href="javascript:;" data-tooltip="{{ _('Delete') }}" name="dczcomments__op-delete-reply"
data-form="{{ {operation: reply_delete_op, drid: doc._id, drrid: rdoc._id}|json }}"
><span class="icon icon-delete"></span></a>
{{ action(_('Delete'), 'delete', 'reply', {operation: reply_delete_op, drid: doc._id, drrid: rdoc._id}, 'delete') }}
{% endif %}
</div>
</div>
@@ -77,7 +77,8 @@
reply_edit_perm = perm.PERM_NEVER,
reply_delete_perm = perm.PERM_DELETE_DISCUSSION_REPLY_SELF_DISCUSSION if handler.user.own(ddoc) else perm.PERM_DELETE_DISCUSSION_REPLY,
reply_edit_self_perm = perm.PERM_EDIT_DISCUSSION_REPLY_SELF,
reply_delete_self_perm = perm.PERM_DELETE_DISCUSSION_REPLY_SELF
reply_delete_self_perm = perm.PERM_DELETE_DISCUSSION_REPLY_SELF,
reactions = reactions
) }}
{{ paginator.render(page, pcount) }}
{% if drcount == 0 %}