diff --git a/packages/hydrooj/locales/zh.yaml b/packages/hydrooj/locales/zh.yaml index f62a33105..510455e71 100644 --- a/packages/hydrooj/locales/zh.yaml +++ b/packages/hydrooj/locales/zh.yaml @@ -28,6 +28,7 @@ __langname: 简体中文 'Copy failed :(': '复制失败 :(' 'Current dataset: {0}': '当前测试数据: {0}' 'Discussion: Terms Of Service': 讨论区服务条款 +'First Blood Notice\n{0} solved problem {1} ({2})': '题目一血提示\n{0} 解决了题目 {1} ({2})' 'Format: category 1, sub category 1.1, sub category 1.2, ..., sub category 1.x, ..., category n, sub category n.1, sub category n.2, ..., sub category n.m, ...': 格式:分类1, 子分类1.1, 子分类1.2, ..., 子分类1.x, ..., 分类n, 子分类n.1, 子分类n.2, ..., 子分类n.m, ... 'Join {0}': '加入 {0}' 'No': 否 diff --git a/packages/hydrooj/src/handler/home.ts b/packages/hydrooj/src/handler/home.ts index 4f4635100..c2e39fac5 100644 --- a/packages/hydrooj/src/handler/home.ts +++ b/packages/hydrooj/src/handler/home.ts @@ -568,19 +568,22 @@ class HomeMessagesHandler extends Handler { const messages = await message.getByUser(this.user._id); const uids = new Set([ ...messages.map((mdoc) => mdoc.from), - ...messages.map((mdoc) => mdoc.to), + ...messages.flatMap((mdoc) => mdoc.to), ]); const udict = await user.getList('system', Array.from(uids)); // TODO(twd2): improve here: const parsed = {}; for (const m of messages) { - const target = m.from === this.user._id ? m.to : m.from; - parsed[target] ||= { - _id: target, - udoc: { ...udict[target], avatarUrl: avatar(udict[target].avatar) }, - messages: [], - }; - parsed[target].messages.push(m); + const raw = m.from === this.user._id ? m.to : m.from; + const targetArr = Array.isArray(raw) ? raw : [raw]; + for (const target of targetArr) { + parsed[target] ||= { + _id: target, + udoc: { ...udict[target], avatarUrl: avatar(udict[target].avatar) }, + messages: [], + }; + parsed[target].messages.push(m); + } } await user.setById(this.user._id, { unreadMsg: 0 }); this.response.body = { messages: parsed }; @@ -601,19 +604,10 @@ class HomeMessagesHandler extends Handler { @param('messageId', Types.ObjectId) async postDeleteMessage({ }, messageId: ObjectId) { const msg = await message.get(messageId); - if ([msg.from, msg.to].includes(this.user._id)) await message.del(messageId); + if (msg.from === this.user._id) await message.del(messageId); else throw new PermissionError(); this.back(); } - - @param('messageId', Types.ObjectId) - async postRead({ }, messageId: ObjectId) { - const msg = await message.get(messageId); - if ([msg.from, msg.to].includes(this.user._id)) { - await message.setFlag(messageId, message.FLAG_UNREAD); - } else throw new PermissionError(); - this.back(); - } } export const inject = { geoip: { required: false }, oauth: {} }; diff --git a/packages/hydrooj/src/handler/user.ts b/packages/hydrooj/src/handler/user.ts index f93f44e66..a8aecdbc4 100644 --- a/packages/hydrooj/src/handler/user.ts +++ b/packages/hydrooj/src/handler/user.ts @@ -174,7 +174,7 @@ class UserWebauthnHandler extends Handler { async post({ domainId, result, redirect }) { const challenge = this.session.challenge; - if (!challenge) throw new ForbiddenError(); + if (!challenge) throw new ForbiddenError('no-challenge'); const tdoc = await token.get(challenge, token.TYPE_WEBAUTHN); if (!tdoc) throw new InvalidTokenError(token.TYPE_TEXTS[token.TYPE_WEBAUTHN]); const udoc = await (tdoc.uid === 'login' diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index ab12ae17c..b4eef3de6 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -311,7 +311,7 @@ export interface DomainDoc extends Record { // Message export interface MessageDoc { from: number; - to: number; + to: number | number[]; content: string; flag: number; } diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index c2c5739fc..51d4bf4c2 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -1,8 +1,9 @@ import { sumBy } from 'lodash'; import { Filter, ObjectId } from 'mongodb'; import { - Counter, formatSeconds, getAlphabeticId, Time, + Counter, formatSeconds, getAlphabeticId, sleep, Time, } from '@hydrooj/utils/lib/utils'; +import { Context } from '../context'; import { ContestAlreadyAttendedError, ContestNotFoundError, ContestScoreboardHiddenError, ValidationError, @@ -11,14 +12,17 @@ import { BaseUserDict, ContestPrintDoc, ContestRule, ContestRules, ProblemDict, RecordDoc, ScoreboardConfig, ScoreboardNode, ScoreboardRow, SubtaskResult, Tdoc, } from '../interface'; +import avatar from '../lib/avatar'; import bus from '../service/bus'; import db from '../service/db'; import type { Handler } from '../service/server'; import { Optional } from '../typeutils'; import { PERM, STATUS, STATUS_SHORT_TEXTS } from './builtin'; import * as document from './document'; -import problem from './problem'; -import user, { User } from './user'; +import MessageModel from './message'; +import problem, { ProblemModel } from './problem'; +import RecordModel from './record'; +import UserModel, { User } from './user'; export enum PrintTaskStatus { pending = 'pending', @@ -233,7 +237,7 @@ const acm = buildContestRule({ async scoreboard(config, _, tdoc, pdict, cursor) { const rankedTsdocs = await db.ranked(cursor, (a, b) => (a.score || 0) === (b.score || 0) && (a.time || 0) === (b.time || 0)); const uids = rankedTsdocs.map(([, tsdoc]) => tsdoc.uid); - const udict = await user.getListForRender(tdoc.domainId, uids, config.showDisplayName ? ['displayName'] : []); + const udict = await UserModel.getListForRender(tdoc.domainId, uids, config.showDisplayName ? ['displayName'] : []); // Find first accept const first = {}; const data = await document.collStatus.aggregate([ @@ -407,7 +411,7 @@ const oi = buildContestRule({ async scoreboard(config, _, tdoc, pdict, cursor) { const rankedTsdocs = await db.ranked(cursor, (a, b) => (a.score || 0) === (b.score || 0)); const uids = rankedTsdocs.map(([, tsdoc]) => tsdoc.uid); - const udict = await user.getListForRender(tdoc.domainId, uids, config.showDisplayName ? ['displayName'] : []); + const udict = await UserModel.getListForRender(tdoc.domainId, uids, config.showDisplayName ? ['displayName'] : []); const psdict = {}; const first = {}; const useRelativeTime = !!tdoc.duration; @@ -776,7 +780,7 @@ const homework = buildContestRule({ async scoreboard(config, _, tdoc, pdict, cursor) { const rankedTsdocs = await db.ranked(cursor, (a, b) => a.score === b.score); const uids = rankedTsdocs.map(([, tsdoc]) => tsdoc.uid); - const udict = await user.getListForRender(tdoc.domainId, uids, config.showDisplayName ? ['displayName'] : []); + const udict = await UserModel.getListForRender(tdoc.domainId, uids, config.showDisplayName ? ['displayName'] : []); const columns = await this.scoreboardHeader(config, _, tdoc, pdict); const rows: ScoreboardRow[] = [ columns, @@ -851,16 +855,30 @@ export async function getRelated(domainId: string, pid: number, rule?: string) { } export async function addBalloon(domainId: string, tid: ObjectId, uid: number, rid: ObjectId, pid: number) { - const balloon = await collBalloon.findOne({ - domainId, tid, pid, uid, - }); - if (balloon) return null; - const bdcount = await collBalloon.countDocuments({ domainId, tid, pid }); + const balloon = await collBalloon.find({ domainId, tid, pid }).project({ uid: 1 }).toArray(); + if (balloon.find((i) => i.uid === uid)) return null; + let isFirst = !balloon.length; + if (isFirst) { + let pending: RecordDoc[] = []; + do { + if (pending.length) await sleep(500); // eslint-disable-line no-await-in-loop + pending = await RecordModel.getMulti(domainId, { // eslint-disable-line no-await-in-loop + pid, contest: tid, _id: { $lt: rid }, status: { + $in: [ + STATUS.STATUS_WAITING, STATUS.STATUS_COMPILING, + STATUS.STATUS_JUDGING, STATUS.STATUS_FETCHED, + STATUS.STATUS_ACCEPTED, + ], + }, + }).limit(1).toArray(); + } while (pending.length && !pending.some((i) => i.status === STATUS.STATUS_ACCEPTED)); + if (pending.some((i) => i.status === STATUS.STATUS_ACCEPTED)) isFirst = false; + } const newBdoc = { - _id: rid, domainId, tid, pid, uid, ...(!bdcount ? { first: true } : {}), + _id: rid, domainId, tid, pid, uid, ...(isFirst ? { first: true } : {}), }; await collBalloon.insertOne(newBdoc); - bus.broadcast('contest/balloon', domainId, tid, newBdoc); + bus.emit('contest/balloon', domainId, tid, newBdoc); return rid; } @@ -1090,7 +1108,34 @@ export function getMultiPrintTask(domainId: string, tid: ObjectId, query = {}) { .sort({ _id: 1 }); } +export async function apply(ctx: Context) { + ctx.on('contest/balloon', (domainId, tid, bdoc) => { + if (!bdoc.first) return; + (async () => { + const tsdocs = await getMultiStatus(domainId, { docId: tid, subscribe: 1 }).toArray(); + const uids = Array.from(new Set(tsdocs.map((tsdoc) => tsdoc.uid))); + const [team, tdoc, pdoc] = await Promise.all([ + UserModel.getById(domainId, bdoc.uid), + get(domainId, tid), + ProblemModel.get(domainId, bdoc.pid), + ]); + await MessageModel.send(1, uids, JSON.stringify({ + message: 'First Blood Notice\n{0} solved problem {1} ({2})', + avatar: avatar(team.avatar), + params: [team.uname, getAlphabeticId(tdoc.pids.indexOf(bdoc.pid)), pdoc.title], + }), MessageModel.FLAG_I18N); + })(); + }); + await ctx.db.ensureIndexes( + collBalloon, + { key: { domainId: 1, tid: 1, pid: 1, uid: 1 }, unique: true, name: 'basic' }, + { key: { domainId: 1, tid: 1, pid: 1 }, unique: true, name: 'first', partialFilterExpression: { first: true } }, + ); +} + global.Hydro.model.contest = { + apply, + RULES, PrintTaskStatus, buildContestRule, diff --git a/packages/hydrooj/src/model/message.ts b/packages/hydrooj/src/model/message.ts index 2ffcd4d68..673973340 100644 --- a/packages/hydrooj/src/model/message.ts +++ b/packages/hydrooj/src/model/message.ts @@ -22,9 +22,9 @@ class MessageModel { content: string, flag: number = MessageModel.FLAG_UNREAD, ) { if (!Array.isArray(to)) to = [to]; - const base = { from, content, flag }; + const base = { from, content, flag, to }; if (!to.length) return base; - await MessageModel.coll.insertMany(to.map((t) => ({ ...base, to: t }))); + await MessageModel.coll.insertOne(base); bus.broadcast('user/message', to, base); if (flag & MessageModel.FLAG_UNREAD) await user.inc(to, 'unreadMsg', 1); return base; @@ -52,14 +52,6 @@ class MessageModel { .toArray(); } - static async setFlag(messageId: ObjectId, flag: number) { - return await MessageModel.coll.findOneAndUpdate( - { _id: messageId }, - { $bit: { flag: { xor: flag } } }, - { returnDocument: 'after' }, - ); - } - static async del(_id: ObjectId) { return await MessageModel.coll.deleteOne({ _id }); } diff --git a/packages/hydrooj/src/service/db.ts b/packages/hydrooj/src/service/db.ts index 626229360..f4904bd6d 100644 --- a/packages/hydrooj/src/service/db.ts +++ b/packages/hydrooj/src/service/db.ts @@ -120,7 +120,11 @@ export class MongoService extends Service { index.background = true; if (!i) { logger.info('Indexing %s.%s with key %o', coll.collectionName, index.name, index.key); - await coll.createIndexes([index]); + try { + await coll.createIndexes([index]); + } catch (e) { + logger.error('Failed to index %s.%s with key %o: %s', coll.collectionName, index.name, index.key, e); + } continue; } const isDifferent = () => { @@ -137,7 +141,11 @@ export class MongoService extends Service { } logger.info('Re-Index %s.%s with key %o', coll.collectionName, index.name, index.key); await coll.dropIndex(i.name); - await coll.createIndexes([index]); + try { + await coll.createIndexes([index]); + } catch (e) { + logger.error('Failed to re-index %s.%s with key %o: %s', coll.collectionName, index.name, index.key, e); + } } } } diff --git a/packages/ui-default/components/message/index.page.ts b/packages/ui-default/components/message/index.page.ts index 9a3727e85..f34787620 100644 --- a/packages/ui-default/components/message/index.page.ts +++ b/packages/ui-default/components/message/index.page.ts @@ -9,17 +9,22 @@ import { i18n, tpl } from 'vj/utils'; import Sock from '../socket'; let previous: VjNotification; -const onmessage = (msg) => { - console.log('Received message', msg); +const processI18n = (msg) => { if (msg.mdoc.flag & FLAG_I18N) { try { msg.mdoc.content = JSON.parse(msg.mdoc.content); if (msg.mdoc.content.url) msg.mdoc.url = msg.mdoc.content.url; + if (msg.mdoc.content.avatar) msg.mdoc.avatar = msg.mdoc.content.avatar; msg.mdoc.content = i18n(msg.mdoc.content.message, ...msg.mdoc.content.params); } catch (e) { msg.mdoc.content = i18n(msg.mdoc.content); } } + return msg; +}; +const onmessage = (msg) => { + console.log('Received message', msg); + msg = processI18n(msg); if (msg.mdoc.flag & FLAG_ALERT) { // Is alert new InfoDialog({ @@ -48,6 +53,7 @@ const onmessage = (msg) => { ? { type: 'info', message: msg.mdoc.flag & FLAG_RICHTEXT ? i18n('You received a system message, click here to view.') : msg.mdoc.content, + ...(msg.mdoc.avatar ? { avatar: msg.mdoc.avatar } : {}), } : { title: msg.udoc.uname, avatar: msg.udoc.avatarUrl, @@ -72,6 +78,8 @@ const initWorkerMode = (endpoint) => { const { payload, type } = message.data; if (type === 'message') { if (onmessage(payload)) worker.port.postMessage({ type: 'ack', id: payload.mdoc._id }); + } else if (type === 'i18n') { + worker.port.postMessage({ type: 'ack', id: `${payload.mdoc._id}-i18n`, payload: processI18n(payload) }); } else if (type === 'open-page') { console.log('opening page'); window.open('/home/messages'); diff --git a/packages/ui-default/components/message/worker.ts b/packages/ui-default/components/message/worker.ts index a4e186dfc..d2fa7baac 100644 --- a/packages/ui-default/components/message/worker.ts +++ b/packages/ui-default/components/message/worker.ts @@ -1,7 +1,7 @@ /// /// import ReconnectingWebsocket from 'reconnecting-websocket'; -import { FLAG_INFO } from 'vj/constant/message'; +import { FLAG_I18N, FLAG_INFO } from 'vj/constant/message'; declare const self: SharedWorkerGlobalScope; console.log('SharedWorker init'); @@ -17,6 +17,7 @@ interface RequestInitSharedConnPayload { interface RequestAckPayload { type: 'ack'; id: string; + payload?: any; } interface RequestUnloadPayload { type: 'unload'; @@ -28,28 +29,37 @@ function broadcastMsg(message: any) { for (const p of ports) p.postMessage(message); } -function onMessage(payload: any) { - broadcastMsg({ type: 'message', payload }); +function onMessage(message: any) { + broadcastMsg({ type: 'message', payload: message }); let acked = false; - ack[payload.mdoc._id] = () => { acked = true; }; + let payload = message; + ack[message.mdoc._id] = () => { acked = true; }; + ack[`${message.mdoc._id}-i18n`] = (v) => { payload = v; }; + if (message.mdoc.flag & FLAG_I18N) broadcastMsg({ type: 'i18n', payload: message }); setTimeout(() => { - delete ack[payload.mdoc._id]; + delete ack[message.mdoc._id]; + delete ack[`${message.mdoc._id}-i18n`]; if (acked) return; - if (payload.mdoc.flag & FLAG_INFO) return; + if (message.mdoc.flag & FLAG_INFO) return; if (Notification?.permission !== 'granted') { console.log('Notification permission denied'); return; } + console.log('Sending as system notification'); // eslint-disable-next-line no-new new Notification( - payload.udoc.uname || 'Hydro Notification', - { + payload.udoc._id === 1 ? payload.mdoc.content.split('\n')[0] : payload.udoc.uname || 'Hydro Notification', + payload.udoc._id === 1 ? { + tag: `notification-${payload.mdoc._id}`, + icon: payload.mdoc.avatar || '/android-chrome-192x192.png', + body: payload.mdoc.content.split('\n').slice(1).join('\n'), + } : { tag: `message-${payload.mdoc._id}`, icon: payload.udoc.avatarUrl || '/android-chrome-192x192.png', body: payload.mdoc.content, }, ); - }, 5000); + }, 3000); } function initConn(path: string, port: MessagePort, cookie: any) { @@ -92,7 +102,7 @@ self.onconnect = function (e) { port.addEventListener('message', (msg: { data: RequestPayload }) => { if (msg.data.type === 'conn') initConn(msg.data.path, port, msg.data.cookie); - if (msg.data.type === 'ack') ack[msg.data.id]?.(); + if (msg.data.type === 'ack') ack[msg.data.id]?.(msg.data.payload); if (msg.data.type === 'unload') ports.delete(port); }); diff --git a/packages/ui-default/components/notification/index.ts b/packages/ui-default/components/notification/index.ts index 3aace4689..a9e878023 100644 --- a/packages/ui-default/components/notification/index.ts +++ b/packages/ui-default/components/notification/index.ts @@ -38,11 +38,12 @@ export default class Notification { if (avatar) this.type += ' avatar'; if (title) this.type += ' title'; this.action = action || (() => { }); - this.$dom = $(tpl`
`); - if (avatar) $(tpl``).appendTo(this.$dom); + this.$dom = $(tpl`
`); + if (avatar) $(tpl``).appendTo(this.$dom); + const content = message.split('\n').map((line) => tpl`

${line}

`).join(''); if (title) { - $(tpl`

${title}

${message}

`).appendTo(this.$dom); - } else $(tpl`

${message}

`).appendTo(this.$dom); + $(tpl`

${title}

${{ templateRaw: true, html: content }}
`).appendTo(this.$dom); + } else $(`
${content}
`).appendTo(this.$dom); this.$dom.on('click', this.handleClick.bind(this)); this.$n = this.$dom .css('z-index', zIndexManager.getNext()) diff --git a/packages/ui-default/components/notification/notification.page.styl b/packages/ui-default/components/notification/notification.page.styl index 239b8c32b..c7e76e7ca 100644 --- a/packages/ui-default/components/notification/notification.page.styl +++ b/packages/ui-default/components/notification/notification.page.styl @@ -21,6 +21,15 @@ opacity: 0 transform: translateY(20px) scale(0.9) + &.avatar + display: flex + align-items: center + + .avatar + display: inline-block + vertical-align: top + margin-right: rem(10px) + &:before display: inline-block font-family: $icon-font-name !important @@ -30,6 +39,9 @@ vertical-align: top margin-top: rem((20px - $font-size-icon) / 2) + &.avatar:before + display: none !important + for key, value in $notification-color &.{key} border-left: 5px solid value @@ -37,7 +49,7 @@ color: value for key, value in $notification-icon - &.{key}:before + &:not(.avatar).{key}:before content: value +mobile() diff --git a/packages/ui-default/pages/contest_balloon.page.tsx b/packages/ui-default/pages/contest_balloon.page.tsx index 8696404d7..da2064da1 100644 --- a/packages/ui-default/pages/contest_balloon.page.tsx +++ b/packages/ui-default/pages/contest_balloon.page.tsx @@ -85,7 +85,8 @@ async function handleSetColor(tdoc) { Notification.error(`${e.message} ${e.params?.[0]}`); } Notification.info(i18n('Successfully updated.')); - pjax.request({ url: '', push: false }); + if ($('[data-fragment-id="constest_balloon-tbody"]').length) pjax.request({ url: '', push: false }); + else window.location.reload(); } const page = new NamedPage('contest_balloon', () => {