diff --git a/modules/common/src/main/String.scala b/modules/common/src/main/String.scala index 3dec12da8f54..fc468ec71ab3 100644 --- a/modules/common/src/main/String.scala +++ b/modules/common/src/main/String.scala @@ -21,18 +21,28 @@ object String: try play.utils.UriEncoding.decodePath(input, "UTF-8").some catch case _: play.utils.InvalidUriEncodingException => None - def isShouting(text: String) = + def isShouting(text: String): Boolean = text.lengthIs >= 5 && { import java.lang.Character.* // true if >1/2 of the latin letters are uppercase - text.take(80).replace("O-O", "o-o").foldLeft(0) { (i, c) => - getType(c) match - case UPPERCASE_LETTER => i + 1 - case LOWERCASE_LETTER => i - 1 - case _ => i - } > 0 + if text.contains('\ue666') then + val (before, after) = text.span(_ != '\ue666') + isShouting(before) + else + text.take(80).replace("O-O", "o-o").foldLeft(0) { (i, c) => + getType(c) match + case UPPERCASE_LETTER => i + 1 + case LOWERCASE_LETTER => i - 1 + case _ => i + } > 0 } - def noShouting(str: String): String = if isShouting(str) then str.toLowerCase else str + def noShouting(str: String): String = if isShouting(str) then + // '\ue666' is a special character used to encode broadcast chat messages. See file://./../../../../ui/analyse/src/study/relay/chatHandler.ts + if str.contains('\ue666') then + val (before, after) = str.span(_ != '\ue666') + before.toLowerCase + after + else str.toLowerCase + else str val atUsernameRegex = RawHtml.atUsernameRegex val forumPostPathRegex = """(?:(?<= )|^)\b([\w-]+/[\w-]+)\b(?:(?= )|$)""".r diff --git a/ui/analyse/src/interfaces.ts b/ui/analyse/src/interfaces.ts index 065fbf285433..d26edee6a805 100644 --- a/ui/analyse/src/interfaces.ts +++ b/ui/analyse/src/interfaces.ts @@ -11,6 +11,7 @@ import { AnalyseSocketSend } from './socket'; import { ExternalEngineInfo } from 'ceval'; import * as Prefs from 'common/prefs'; import { EnhanceOpts } from 'common/richText'; +import { BroadcastChatHandler } from 'chat/src/interfaces'; export type Seconds = number; @@ -158,6 +159,7 @@ export interface AnalyseOpts { chat: { enhance: EnhanceOpts; instance?: Promise; + broadcastChatHandler?: BroadcastChatHandler; }; wiki?: boolean; inlinePgn?: string; diff --git a/ui/analyse/src/study/relay/chatHandler.ts b/ui/analyse/src/study/relay/chatHandler.ts new file mode 100644 index 000000000000..2589f387af62 --- /dev/null +++ b/ui/analyse/src/study/relay/chatHandler.ts @@ -0,0 +1,74 @@ +import { BroadcastChatHandler, Line } from 'chat/src/interfaces'; +import AnalyseCtrl from '../../ctrl'; +import { VNode, h } from 'snabbdom'; +import { bind } from 'common/snabbdom'; + +export function broadcastChatHandler(ctrl: AnalyseCtrl): BroadcastChatHandler { + // '\ue666' was arbitrarily chosen from the unicode private use area to separate the text from the chapterId and ply + const separator = '\ue666'; + + const encodeMsg = (text: string): string => { + text = cleanMsg(text); + if (ctrl.study?.relay && !ctrl.study.relay.tourShow()) { + const chapterId = ctrl.study.currentChapter().id; + const ply = ctrl.study.currentNode().ply; + const newText = text + separator + chapterId + separator + ply; + if (newText.length <= 140) { + text = newText; + } + } + return text; + }; + + const cleanMsg = (msg: string): string => { + if (msg.includes(separator) && ctrl.study?.relay) { + return msg.split(separator)[0]; + } + return msg; + }; + + const jumpToMove = async (msg: string) => { + if (msg.includes(separator) && ctrl.study?.relay) { + const segs = msg.split(separator); + if (segs.length == 3) { + const [, chapterId, ply] = segs; + await ctrl.study.setChapter(chapterId); + ctrl.jumpToMain(parseInt(ply)); + } + } + }; + + const canJumpToMove = (msg: string): string | null => { + if (msg.includes(separator) && ctrl.study?.relay) { + const segs = msg.split(separator); + if (segs.length == 3) { + const [, chapterId, ply] = segs; + return `${chapterId}#${ply}`; + } + } + return null; + }; + + const jumpButton = (line: Line): VNode | null => { + const msgPly = canJumpToMove(line.t); + return msgPly + ? h( + 'button.jump', + { + hook: bind('click', () => jumpToMove(line.t)), + attrs: { + title: `Jump to move ${msgPly}`, + }, + }, + '#', + ) + : null; + }; + + return { + encodeMsg, + cleanMsg, + jumpToMove, + jumpButton, + }; +} diff --git a/ui/analyse/src/study/relay/relayCtrl.ts b/ui/analyse/src/study/relay/relayCtrl.ts index 2527f9227ef5..5bf2da2af998 100644 --- a/ui/analyse/src/study/relay/relayCtrl.ts +++ b/ui/analyse/src/study/relay/relayCtrl.ts @@ -35,7 +35,7 @@ export default class RelayCtrl { private readonly chapters: StudyChapters, private readonly multiCloudEval: MultiCloudEval, private readonly federations: () => Federations | undefined, - setChapter: (id: ChapterId | number) => boolean, + setChapter: (id: ChapterId | number) => Promise, ) { this.tourShow = toggle((location.pathname.match(/\//g) || []).length < 5); const locationTab = location.hash.replace(/^#/, '') as RelayTab; diff --git a/ui/analyse/src/study/relay/relayTeams.ts b/ui/analyse/src/study/relay/relayTeams.ts index f2246414ce28..56f78eba1426 100644 --- a/ui/analyse/src/study/relay/relayTeams.ts +++ b/ui/analyse/src/study/relay/relayTeams.ts @@ -31,7 +31,7 @@ export default class RelayTeams { constructor( private readonly roundId: RoundId, readonly multiCloudEval: MultiCloudEval, - readonly setChapter: (id: ChapterId | number) => boolean, + readonly setChapter: (id: ChapterId | number) => Promise, readonly roundPath: () => string, private readonly redraw: Redraw, ) {} diff --git a/ui/analyse/src/study/studyChapters.ts b/ui/analyse/src/study/studyChapters.ts index 15be731c7766..70ab1be3da3c 100644 --- a/ui/analyse/src/study/studyChapters.ts +++ b/ui/analyse/src/study/studyChapters.ts @@ -138,7 +138,7 @@ export function resultOf(tags: TagArray[], isWhite: boolean): string | undefined export const gameLinkAttrs = (basePath: string, game: { id: ChapterId }) => ({ href: `${basePath}/${game.id}`, }); -export const gameLinksListener = (setChapter: (id: ChapterId | number) => boolean) => (vnode: VNode) => +export const gameLinksListener = (setChapter: (id: ChapterId | number) => Promise) => (vnode: VNode) => (vnode.elm as HTMLElement).addEventListener( 'click', e => { @@ -146,7 +146,10 @@ export const gameLinksListener = (setChapter: (id: ChapterId | number) => boolea while (target && target.tagName !== 'A') target = target.parentNode as HTMLLinkElement; const href = target?.href; const id = target?.dataset['board'] || href?.match(/^[^?#]*/)?.[0].slice(-8); - if (id && setChapter(id) && !href?.match(/[?&]embed=/)) e.preventDefault(); + if (id && !href?.match(/[?&]embed=/)) { + setChapter(id); + e.preventDefault(); + } }, { passive: false }, ); diff --git a/ui/analyse/src/study/studyCtrl.ts b/ui/analyse/src/study/studyCtrl.ts index 6866c4091c0e..0c733867adfe 100644 --- a/ui/analyse/src/study/studyCtrl.ts +++ b/ui/analyse/src/study/studyCtrl.ts @@ -377,7 +377,7 @@ export default class StudyCtrl { this.updateAddressBar(); }; - xhrReload = throttlePromiseDelay( + xhrReload: () => Promise = throttlePromiseDelay( () => 500, () => { this.vm.loading = true; @@ -448,31 +448,32 @@ export default class StudyCtrl { likeToggler = debounce(() => this.send('like', { liked: this.data.liked }), 1000); - setChapter = (idOrNumber: ChapterId | number, force?: boolean): boolean => { + setChapter = (idOrNumber: ChapterId | number, force?: boolean): Promise => { const prev = this.chapters.list.get(idOrNumber); const id = prev?.id; if (!id) { console.warn(`Chapter ${idOrNumber} not found`); - return false; + return Promise.reject(); } const alreadySet = id === this.vm.chapterId && !force; if (this.relay?.tourShow()) { this.relay.tourShow(false); if (alreadySet) this.redraw(); } - if (alreadySet) return true; + let result = Promise.resolve(); + if (alreadySet) return result; if (!this.vm.mode.sticky || !this.makeChange('setChapter', id)) { this.vm.mode.sticky = false; if (!this.vm.behind) this.vm.behind = 1; this.vm.chapterId = id; - this.xhrReload(); + result = this.xhrReload(); } this.vm.loading = true; this.vm.nextChapterId = id; this.vm.justSetChapterId = id; this.redraw(); window.scrollTo(0, 0); - return true; + return result; }; private deltaChapter = (delta: number): ChapterPreview | undefined => { @@ -638,7 +639,7 @@ export default class StudyCtrl { (position.path === this.ctrl.path && position.path === treePath.fromNodeList(this.ctrl.mainline)) ) this.ctrl.jump(newPath); - this.redraw(); + return this.redraw(); }, deleteNode: d => { const position = d.p, @@ -650,7 +651,7 @@ export default class StudyCtrl { if (!this.ctrl.tree.pathExists(d.p.path)) return this.xhrReload(); this.ctrl.tree.deleteNodeAt(position.path); if (this.vm.mode.sticky) this.ctrl.jump(this.ctrl.path); - this.redraw(); + return this.redraw(); }, promote: d => { const position = d.p, @@ -663,7 +664,7 @@ export default class StudyCtrl { if (this.vm.mode.sticky) this.ctrl.jump(this.ctrl.path); else if (this.relay) this.ctrl.jump(d.p.path); this.ctrl.treeVersion++; - this.redraw(); + return this.redraw(); }, reload: this.xhrReload, changeChapter: d => { diff --git a/ui/analyse/src/view/components.ts b/ui/analyse/src/view/components.ts index 74308fe36f49..150fffe9d149 100644 --- a/ui/analyse/src/view/components.ts +++ b/ui/analyse/src/view/components.ts @@ -40,6 +40,7 @@ import StudyCtrl from '../study/studyCtrl'; import RelayCtrl from '../study/relay/relayCtrl'; import type * as studyDeps from '../study/studyDeps'; import { renderPgnError } from '../pgnImport'; +import { broadcastChatHandler } from '../study/relay/chatHandler'; export interface ViewContext { ctrl: AnalyseCtrl; @@ -389,6 +390,7 @@ export function makeChat(ctrl: AnalyseCtrl, insert: (chat: HTMLElement) => void) chatEl.classList.add('mchat'); insert(chatEl); const chatOpts = ctrl.opts.chat; + chatOpts.broadcastChatHandler = broadcastChatHandler(ctrl); chatOpts.instance?.then(c => c.destroy()); chatOpts.enhance = { plies: true, boards: !!ctrl.study?.relay }; chatOpts.instance = site.makeChat(chatOpts); diff --git a/ui/chat/css/_discussion.scss b/ui/chat/css/_discussion.scss index b66b29df34e3..0d33d8cd590f 100644 --- a/ui/chat/css/_discussion.scss +++ b/ui/chat/css/_discussion.scss @@ -78,12 +78,7 @@ action { display: none; - position: absolute; - top: 5px; - @include inline-end(0); cursor: pointer; - margin-inline-end: 3px; - padding: 1px 5px; opacity: 0.7; color: $c-accent; @@ -111,6 +106,29 @@ } } + button.jump { + font-size: medium; + cursor: pointer; + background-color: transparent; + color: $c-link; + border: none; + } + + button.jump:hover { + color: $c-link-hover; + } + + div.actions { + position: absolute; + @include inline-end(0); + display: flex; + flex-direction: row; + top: 5px; + gap: 6px; + margin-inline-end: 3px; + padding: 1px 5px; + } + &__say { flex: 0 0 auto; border: 0; diff --git a/ui/chat/src/ctrl.ts b/ui/chat/src/ctrl.ts index 1fbf0e1496e3..6c408d802823 100644 --- a/ui/chat/src/ctrl.ts +++ b/ui/chat/src/ctrl.ts @@ -9,6 +9,7 @@ import { ChatData, NoteCtrl, ChatPalantir, + BroadcastChatHandler, } from './interfaces'; import { PresetCtrl, presetCtrl } from './preset'; import { noteCtrl } from './note'; @@ -30,12 +31,14 @@ export default class ChatCtrl { preset: PresetCtrl; trans: Trans; vm: ViewModel; + broadcastChatHandler: BroadcastChatHandler; constructor( readonly opts: ChatOpts, readonly redraw: Redraw, ) { this.data = opts.data; + this.broadcastChatHandler = opts.broadcastChatHandler; if (opts.noteId) this.allTabs.push('note'); if (opts.plugin) this.allTabs.push(opts.plugin.tab.key); this.palantir = { @@ -102,6 +105,8 @@ export default class ChatCtrl { alert('Max length: 140 chars. ' + text.length + ' chars used.'); return false; } + if (this.broadcastChatHandler) text = this.broadcastChatHandler.encodeMsg(text); + site.pubsub.emit('socket.send', 'talk', text); return true; }; diff --git a/ui/chat/src/discussion.ts b/ui/chat/src/discussion.ts index b11894d3151d..c9d07ca8fa75 100644 --- a/ui/chat/src/discussion.ts +++ b/ui/chat/src/discussion.ts @@ -188,7 +188,7 @@ const userThunk = (name: string, title?: string, patron?: boolean, flair?: Flair userLink({ name, title, patron, line: !!patron, flair }); function renderLine(ctrl: ChatCtrl, line: Line): VNode { - const textNode = renderText(line.t, ctrl.opts.enhance); + const textNode = renderText(ctrl.broadcastChatHandler?.cleanMsg(line.t) || line.t, ctrl.opts.enhance); if (line.u === 'lichess') return h('li.system', textNode); @@ -204,6 +204,8 @@ function renderLine(ctrl: ChatCtrl, line: Line): VNode { .match(enhance.userPattern) ?.find(mention => mention.trim().toLowerCase() == `@${ctrl.data.userId}`); + const jumpButton = ctrl.broadcastChatHandler?.jumpButton(line); + return h( 'li', { @@ -214,13 +216,16 @@ function renderLine(ctrl: ChatCtrl, line: Line): VNode { }, }, ctrl.moderation - ? [line.u ? modLineAction() : null, userNode, ' ', textNode] + ? [h('div.actions', [line.u ? modLineAction() : null, jumpButton]), userNode, ' ', textNode] : [ - myUserId && line.u && myUserId != line.u - ? h('action.flag', { - attrs: { 'data-icon': licon.CautionTriangle, title: 'Report' }, - }) - : null, + h('div.actions', [ + myUserId && line.u && myUserId != line.u + ? h('action.flag', { + attrs: { 'data-icon': licon.CautionTriangle, title: 'Report' }, + }) + : null, + jumpButton, + ]), userNode, ' ', textNode, diff --git a/ui/chat/src/interfaces.ts b/ui/chat/src/interfaces.ts index 5f440de1e0c7..21912485d939 100644 --- a/ui/chat/src/interfaces.ts +++ b/ui/chat/src/interfaces.ts @@ -12,6 +12,7 @@ export interface ChatOpts { blind: boolean; timeout: boolean; enhance?: EnhanceOpts; + broadcastChatHandler: BroadcastChatHandler; public: boolean; permissions: Permissions; timeoutReasons?: ModerationReason[]; @@ -54,6 +55,13 @@ export interface Line { title?: string; } +export interface BroadcastChatHandler { + encodeMsg(msg: string): string; + cleanMsg(msg: string): string; + jumpToMove(msg: string): void; + jumpButton(line: Line): VNode | null; +} + export interface Permissions { local?: boolean; broadcast?: boolean; diff --git a/ui/common/src/throttle.ts b/ui/common/src/throttle.ts index f840889ca6a2..8123afb428af 100644 --- a/ui/common/src/throttle.ts +++ b/ui/common/src/throttle.ts @@ -3,25 +3,61 @@ * flight. Any extra calls are dropped, except the last one, which waits for * the previous call to complete. */ -export function throttlePromise Promise>( +export function throttlePromiseWithResult Promise>( wrapped: T, -): (...args: Parameters) => void { - let current: Promise | undefined; - let afterCurrent: (() => void) | undefined; +): (...args: Parameters) => Promise { + let current: Promise | undefined; + let pending: + | { + run: () => Promise; + reject: () => void; + } + | undefined; - return function (this: any, ...args: Parameters): void { + return function (this: any, ...args: Parameters): Promise { const self = this; - const exec = () => { - afterCurrent = undefined; + const runCurrent = () => { current = wrapped.apply(self, args).finally(() => { current = undefined; - if (afterCurrent) afterCurrent(); + if (pending) { + pending.run(); + pending = undefined; + } }); + return current; }; - if (current) afterCurrent = exec; - else exec(); + if (!current) return runCurrent(); + + pending?.reject(); + const next = new Promise((resolve, reject) => { + pending = { + run: () => + runCurrent().then( + res => { + resolve(res); + return res; + }, + err => { + reject(err); + throw err; + }, + ), + reject: () => reject(new Error('Throttled')), + }; + }); + return next; + }; +} + +/* doesn't fail the promise if it's throttled */ +export function throttlePromise Promise>( + wrapped: T, +): (...args: Parameters) => Promise { + const throttler = throttlePromiseWithResult(wrapped); + return function (this: any, ...args: Parameters): Promise { + return throttler.apply(this, args).catch(() => {}); }; } @@ -49,7 +85,7 @@ export function finallyDelay Promise>( export function throttlePromiseDelay Promise>( delay: (...args: Parameters) => number, wrapped: T, -): (...args: Parameters) => void { +): (...args: Parameters) => Promise { return throttlePromise(finallyDelay(delay, wrapped)); }