fix: Resolve build errors, circular dependencies, and config sync loop (@eikomaniac) (#7173)

### Description

- Break circular dependencies in tribes module by extracting
`navigate-event.ts` and `tribe-auto-join.ts`
- Fix TypeScript build and runtime errors across tribes-related files
- Prevent infinite config sync loop in tribes lobby
- + code quality fixes

---------

Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
eikomaniac 2025-12-01 17:23:16 +00:00 committed by GitHub
parent 0318ee8c1e
commit 42041954b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 214 additions and 789 deletions

View file

@ -1,38 +1,16 @@
import * as ResultDAL from "../../dal/result";
import * as PublicDAL from "../../dal/public";
import {
isDevEnvironment,
omit,
replaceObjectId,
replaceObjectIds,
} from "../../utils/misc";
import objectHash from "object-hash";
import Logger from "../../utils/logger";
import "dotenv/config";
import { MonkeyResponse } from "../../utils/monkey-response";
import MonkeyError from "../../utils/error";
import { isTestTooShort } from "../../utils/validation";
import {
implemented as anticheatImplemented,
validateResult,
validateKeys,
} from "../../anticheat/index";
import MonkeyStatusCodes from "../../constants/monkey-status-codes";
import {
incrementResult,
incrementDailyLeaderboard,
} from "../../utils/prometheus";
import GeorgeQueue from "../../queues/george-queue";
import { getDailyLeaderboard } from "../../utils/daily-leaderboards";
import AutoRoleList from "../../constants/auto-roles";
import { implemented as anticheatImplemented } from "../../anticheat/index";
import * as UserDAL from "../../dal/user";
import { buildMonkeyMail } from "../../utils/monkey-mail";
import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard";
import { UAParser } from "ua-parser-js";
import { canFunboxGetPb } from "../../utils/pb";
import { buildDbResult } from "../../utils/result";
import { Configuration } from "@monkeytype/schemas/configuration";
import { addImportantLog, addLog } from "../../dal/logs";
import { addLog } from "../../dal/logs";
import {
AddResultRequest,
AddResultResponse,
@ -44,26 +22,7 @@ import {
UpdateResultTagsRequest,
UpdateResultTagsResponse,
} from "@monkeytype/contracts/results";
import {
CompletedEvent,
KeyStats,
PostResultResponse,
XpBreakdown,
} from "@monkeytype/schemas/results";
import {
isSafeNumber,
mapRange,
roundTo2,
stdDev,
} from "@monkeytype/util/numbers";
import {
getCurrentDayTimestamp,
getStartOfDayTimestamp,
} from "@monkeytype/util/date-and-time";
import { MonkeyRequest } from "../types";
import { getFunbox, checkCompatibility } from "@monkeytype/funbox";
import { tryCatch } from "@monkeytype/util/trycatch";
import { getCachedConfiguration } from "../../init/configuration";
try {
if (!anticheatImplemented()) throw new Error("undefined");
@ -197,649 +156,16 @@ export async function updateTags(
}
export async function addResult(
req: MonkeyRequest<undefined, AddResultRequest>
_req: MonkeyRequest<undefined, AddResultRequest>
): Promise<AddResultResponse> {
// todo remove
return new MonkeyResponse("Result added");
const { uid } = req.ctx.decodedToken;
const user = await UserDAL.getUser(uid, "add result");
if (user.needsToChangeName) {
throw new MonkeyError(
403,
"Please change your name before submitting a result"
);
}
const completedEvent = req.body.result;
completedEvent.uid = uid;
if (isTestTooShort(completedEvent)) {
const status = MonkeyStatusCodes.TEST_TOO_SHORT;
throw new MonkeyError(status.code, status.message);
}
if (user.lbOptOut !== true && completedEvent.acc < 75) {
throw new MonkeyError(400, "Accuracy too low");
}
const resulthash = completedEvent.hash;
if (req.ctx.configuration.results.objectHashCheckEnabled) {
const objectToHash = omit(completedEvent, ["hash"]);
const serverhash = objectHash(objectToHash);
if (serverhash !== resulthash) {
void addLog(
"incorrect_result_hash",
{
serverhash,
resulthash,
result: completedEvent,
},
uid
);
const status = MonkeyStatusCodes.RESULT_HASH_INVALID;
throw new MonkeyError(status.code, "Incorrect result hash");
}
} else {
Logger.warning("Object hash check is disabled, skipping hash check");
}
if (completedEvent.funbox.length !== new Set(completedEvent.funbox).size) {
throw new MonkeyError(400, "Duplicate funboxes");
}
if (!checkCompatibility(completedEvent.funbox)) {
throw new MonkeyError(400, "Impossible funbox combination");
}
let keySpacingStats: KeyStats | undefined = undefined;
if (
completedEvent.keySpacing !== "toolong" &&
completedEvent.keySpacing.length > 0
) {
keySpacingStats = {
average:
completedEvent.keySpacing.reduce(
(previous, current) => (current += previous)
) / completedEvent.keySpacing.length,
sd: stdDev(completedEvent.keySpacing),
};
}
let keyDurationStats: KeyStats | undefined = undefined;
if (
completedEvent.keyDuration !== "toolong" &&
completedEvent.keyDuration.length > 0
) {
keyDurationStats = {
average:
completedEvent.keyDuration.reduce(
(previous, current) => (current += previous)
) / completedEvent.keyDuration.length,
sd: stdDev(completedEvent.keyDuration),
};
}
if (user.suspicious && completedEvent.testDuration <= 120) {
await addImportantLog("suspicious_user_result", completedEvent, uid);
}
if (
completedEvent.mode === "time" &&
(completedEvent.mode2 === "60" || completedEvent.mode2 === "15") &&
completedEvent.wpm > 250 &&
user.lbOptOut !== true
) {
await addImportantLog("highwpm_user_result", completedEvent, uid);
}
if (anticheatImplemented()) {
if (
!validateResult(
completedEvent,
((req.raw.headers["x-client-version"] as string) ||
req.raw.headers["client-version"]) as string,
JSON.stringify(new UAParser(req.raw.headers["user-agent"]).getResult()),
user.lbOptOut === true
)
) {
const status = MonkeyStatusCodes.RESULT_DATA_INVALID;
throw new MonkeyError(status.code, "Result data doesn't make sense");
} else if (isDevEnvironment()) {
Logger.success("Result data validated");
}
} else {
if (!isDevEnvironment()) {
throw new Error("No anticheat module found");
}
Logger.warning(
"No anticheat module found. Continuing in dev mode, results will not be validated."
);
}
//dont use - result timestamp is unreliable, can be changed by system time and stuff
// if (result.timestamp > Math.round(Date.now() / 1000) * 1000 + 10) {
// log(
// "time_traveler",
// {
// resultTimestamp: result.timestamp,
// serverTimestamp: Math.round(Date.now() / 1000) * 1000 + 10,
// },
// uid
// );
// return res.status(400).json({ message: "Time traveler detected" });
const { data: lastResultTimestamp } = await tryCatch(
ResultDAL.getLastResultTimestamp(uid)
);
//convert result test duration to miliseconds
completedEvent.timestamp = Math.floor(Date.now() / 1000) * 1000;
//check if now is earlier than last result plus duration (-1 second as a buffer)
const testDurationMilis = completedEvent.testDuration * 1000;
const incompleteTestsMilis = completedEvent.incompleteTestSeconds * 1000;
const earliestPossible =
(lastResultTimestamp ?? 0) + testDurationMilis + incompleteTestsMilis;
const nowNoMilis = Math.floor(Date.now() / 1000) * 1000;
if (
isSafeNumber(lastResultTimestamp) &&
nowNoMilis < earliestPossible - 1000
) {
void addLog(
"invalid_result_spacing",
{
lastTimestamp: lastResultTimestamp,
earliestPossible,
now: nowNoMilis,
testDuration: testDurationMilis,
difference: nowNoMilis - earliestPossible,
},
uid
);
const status = MonkeyStatusCodes.RESULT_SPACING_INVALID;
throw new MonkeyError(status.code, "Invalid result spacing");
}
//check keyspacing and duration here for bots
if (
completedEvent.mode === "time" &&
completedEvent.wpm > 130 &&
completedEvent.testDuration < 122 &&
(user.verified === false || user.verified === undefined) &&
user.lbOptOut !== true
) {
if (!keySpacingStats || !keyDurationStats) {
const status = MonkeyStatusCodes.MISSING_KEY_DATA;
throw new MonkeyError(status.code, "Missing key data");
}
if (completedEvent.keyOverlap === undefined) {
throw new MonkeyError(400, "Old key data format");
}
if (anticheatImplemented()) {
if (
!validateKeys(completedEvent, keySpacingStats, keyDurationStats, uid)
) {
//autoban
const autoBanConfig = req.ctx.configuration.users.autoBan;
if (autoBanConfig.enabled) {
const didUserGetBanned = await UserDAL.recordAutoBanEvent(
uid,
autoBanConfig.maxCount,
autoBanConfig.maxHours
);
if (didUserGetBanned) {
const mail = buildMonkeyMail({
subject: "Banned",
body: "Your account has been automatically banned for triggering the anticheat system. If you believe this is a mistake, please contact support.",
});
await UserDAL.addToInbox(
uid,
[mail],
req.ctx.configuration.users.inbox
);
user.banned = true;
}
}
const status = MonkeyStatusCodes.BOT_DETECTED;
throw new MonkeyError(status.code, "Possible bot detected");
}
} else {
if (!isDevEnvironment()) {
throw new Error("No anticheat module found");
}
Logger.warning(
"No anticheat module found. Continuing in dev mode, results will not be validated."
);
}
}
if (req.ctx.configuration.users.lastHashesCheck.enabled) {
let lastHashes = user.lastReultHashes ?? [];
if (lastHashes.includes(resulthash)) {
void addLog(
"duplicate_result",
{
lastHashes,
resulthash,
result: completedEvent,
},
uid
);
const status = MonkeyStatusCodes.DUPLICATE_RESULT;
throw new MonkeyError(status.code, "Duplicate result");
} else {
lastHashes.unshift(resulthash);
const maxHashes = req.ctx.configuration.users.lastHashesCheck.maxHashes;
if (lastHashes.length > maxHashes) {
lastHashes = lastHashes.slice(0, maxHashes);
}
await UserDAL.updateLastHashes(uid, lastHashes);
}
}
if (keyDurationStats) {
keyDurationStats.average = roundTo2(keyDurationStats.average);
keyDurationStats.sd = roundTo2(keyDurationStats.sd);
}
if (keySpacingStats) {
keySpacingStats.average = roundTo2(keySpacingStats.average);
keySpacingStats.sd = roundTo2(keySpacingStats.sd);
}
let isPb = false;
let tagPbs: string[] = [];
if (!completedEvent.bailedOut) {
[isPb, tagPbs] = await Promise.all([
UserDAL.checkIfPb(uid, user, completedEvent),
UserDAL.checkIfTagPb(uid, user, completedEvent),
]);
}
if (completedEvent.mode === "time" && completedEvent.mode2 === "60") {
void UserDAL.incrementBananas(uid, completedEvent.wpm);
if (
isPb &&
user.discordId !== undefined &&
user.discordId !== "" &&
user.lbOptOut !== true
) {
void GeorgeQueue.updateDiscordRole(user.discordId, completedEvent.wpm);
}
}
if (
completedEvent.challenge !== null &&
completedEvent.challenge !== undefined &&
AutoRoleList.includes(completedEvent.challenge) &&
user.discordId !== undefined &&
user.discordId !== ""
) {
void GeorgeQueue.awardChallenge(user.discordId, completedEvent.challenge);
} else {
delete completedEvent.challenge;
}
const afk = completedEvent.afkDuration ?? 0;
const totalDurationTypedSeconds =
completedEvent.testDuration + completedEvent.incompleteTestSeconds - afk;
void UserDAL.updateTypingStats(
uid,
completedEvent.restartCount,
totalDurationTypedSeconds
);
void PublicDAL.updateStats(
completedEvent.restartCount,
totalDurationTypedSeconds
);
const dailyLeaderboardsConfig = req.ctx.configuration.dailyLeaderboards;
const dailyLeaderboard = getDailyLeaderboard(
completedEvent.language,
completedEvent.mode,
completedEvent.mode2,
dailyLeaderboardsConfig
);
let dailyLeaderboardRank = -1;
const stopOnLetterTriggered =
completedEvent.stopOnLetter && completedEvent.acc < 100;
const minTimeTyping = (await getCachedConfiguration(true)).leaderboards
.minTimeTyping;
const userEligibleForLeaderboard =
user.banned !== true &&
user.lbOptOut !== true &&
(isDevEnvironment() || (user.timeTyping ?? 0) > minTimeTyping);
const validResultCriteria =
canFunboxGetPb(completedEvent) &&
!completedEvent.bailedOut &&
userEligibleForLeaderboard &&
!stopOnLetterTriggered;
const selectedBadgeId = user.inventory?.badges?.find((b) => b.selected)?.id;
const isPremium =
(await UserDAL.checkIfUserIsPremium(user.uid, user)) || undefined;
if (dailyLeaderboard && validResultCriteria) {
incrementDailyLeaderboard(
completedEvent.mode,
completedEvent.mode2,
completedEvent.language
);
dailyLeaderboardRank = await dailyLeaderboard.addResult(
{
name: user.name,
wpm: completedEvent.wpm,
raw: completedEvent.rawWpm,
acc: completedEvent.acc,
consistency: completedEvent.consistency,
timestamp: completedEvent.timestamp,
uid,
discordAvatar: user.discordAvatar,
discordId: user.discordId,
badgeId: selectedBadgeId,
isPremium,
},
dailyLeaderboardsConfig
);
if (
dailyLeaderboardRank >= 1 &&
dailyLeaderboardRank <= 10 &&
completedEvent.testDuration <= 120
) {
const now = Date.now();
const reset = getCurrentDayTimestamp();
const limit = 6 * 60 * 60 * 1000;
if (now - reset >= limit) {
await addLog("daily_leaderboard_top_10_result", completedEvent, uid);
}
}
}
const streak = await UserDAL.updateStreak(uid, completedEvent.timestamp);
const badgeWaitingInInbox = (
user.inbox?.flatMap((i) =>
(i.rewards ?? []).map((r) => (r.type === "badge" ? r.item.id : null))
) ?? []
).includes(14);
const shouldGetBadge =
streak >= 365 &&
user.inventory?.badges?.find((b) => b.id === 14) === undefined &&
!badgeWaitingInInbox;
if (shouldGetBadge) {
const mail = buildMonkeyMail({
subject: "Badge",
body: "Congratulations for reaching a 365 day streak! You have been awarded a special badge. Now, go touch some grass.",
rewards: [
{
type: "badge",
item: {
id: 14,
},
},
],
});
await UserDAL.addToInbox(uid, [mail], req.ctx.configuration.users.inbox);
}
const xpGained = await calculateXp(
completedEvent,
req.ctx.configuration.users.xp,
lastResultTimestamp,
user.xp ?? 0,
streak
);
if (xpGained.xp < 0) {
throw new MonkeyError(
500,
"Calculated XP is negative",
JSON.stringify({
xpGained,
result: completedEvent,
}),
uid
);
}
const weeklyXpLeaderboardConfig = req.ctx.configuration.leaderboards.weeklyXp;
let weeklyXpLeaderboardRank = -1;
const weeklyXpLeaderboard = WeeklyXpLeaderboard.get(
weeklyXpLeaderboardConfig
);
if (userEligibleForLeaderboard && xpGained.xp > 0 && weeklyXpLeaderboard) {
weeklyXpLeaderboardRank = await weeklyXpLeaderboard.addResult(
weeklyXpLeaderboardConfig,
{
entry: {
uid,
name: user.name,
discordAvatar: user.discordAvatar,
discordId: user.discordId,
badgeId: selectedBadgeId,
lastActivityTimestamp: Date.now(),
isPremium,
timeTypedSeconds: totalDurationTypedSeconds,
},
xpGained: xpGained.xp,
}
);
}
const dbresult = buildDbResult(completedEvent, user.name, isPb);
if (keySpacingStats !== undefined) {
dbresult.keySpacingStats = keySpacingStats;
}
if (keyDurationStats !== undefined) {
dbresult.keyDurationStats = keyDurationStats;
}
const addedResult = await ResultDAL.addResult(uid, dbresult);
await UserDAL.incrementXp(uid, xpGained.xp);
await UserDAL.incrementTestActivity(user, completedEvent.timestamp);
if (isPb) {
void addLog(
"user_new_pb",
`${completedEvent.mode + " " + completedEvent.mode2} ${
completedEvent.wpm
} ${completedEvent.acc}% ${completedEvent.rawWpm} ${
completedEvent.consistency
}% (${addedResult.insertedId})`,
uid
);
}
const data: PostResultResponse = {
isPb,
tagPbs,
insertedId: addedResult.insertedId.toHexString(),
xp: xpGained.xp,
dailyXpBonus: xpGained.dailyBonus ?? false,
xpBreakdown: xpGained.breakdown ?? {},
streak,
};
if (dailyLeaderboardRank !== -1) {
data.dailyLeaderboardRank = dailyLeaderboardRank;
}
if (weeklyXpLeaderboardRank !== -1) {
data.weeklyXpLeaderboardRank = weeklyXpLeaderboardRank;
}
incrementResult(completedEvent, dbresult.isPb);
return new MonkeyResponse("Result saved", data);
}
type XpResult = {
xp: number;
dailyBonus?: boolean;
breakdown?: XpBreakdown;
};
async function calculateXp(
result: CompletedEvent,
xpConfiguration: Configuration["users"]["xp"],
lastResultTimestamp: number | null,
currentTotalXp: number,
streak: number
): Promise<XpResult> {
const {
mode,
acc,
testDuration,
incompleteTestSeconds,
incompleteTests,
afkDuration,
charStats,
punctuation,
numbers,
funbox: resultFunboxes,
} = result;
const {
enabled,
gainMultiplier,
maxDailyBonus,
minDailyBonus,
funboxBonus: funboxBonusConfiguration,
} = xpConfiguration;
if (mode === "zen" || !enabled) {
return {
xp: 0,
};
}
const breakdown: XpBreakdown = {};
const baseXp = Math.round((testDuration - afkDuration) * 2);
breakdown.base = baseXp;
let modifier = 1;
const correctedEverything = charStats
.slice(1)
.every((charStat: number) => charStat === 0);
if (acc === 100) {
modifier += 0.5;
breakdown.fullAccuracy = Math.round(baseXp * 0.5);
} else if (correctedEverything) {
// corrected everything bonus
modifier += 0.25;
breakdown["corrected"] = Math.round(baseXp * 0.25);
}
if (mode === "quote") {
// real sentences bonus
modifier += 0.5;
breakdown.quote = Math.round(baseXp * 0.5);
} else {
// punctuation bonus
if (punctuation) {
modifier += 0.4;
breakdown.punctuation = Math.round(baseXp * 0.4);
}
if (numbers) {
modifier += 0.1;
breakdown.numbers = Math.round(baseXp * 0.1);
}
}
if (funboxBonusConfiguration > 0 && resultFunboxes.length !== 0) {
const funboxModifier = resultFunboxes.reduce((sum, funboxName) => {
const funbox = getFunbox(funboxName);
const difficultyLevel = funbox?.difficultyLevel ?? 0;
return sum + Math.max(difficultyLevel * funboxBonusConfiguration, 0);
}, 0);
if (funboxModifier > 0) {
modifier += funboxModifier;
breakdown.funbox = Math.round(baseXp * funboxModifier);
}
}
if (xpConfiguration.streak.enabled) {
const streakModifier = parseFloat(
mapRange(
streak,
0,
xpConfiguration.streak.maxStreakDays,
0,
xpConfiguration.streak.maxStreakMultiplier,
true
).toFixed(1)
);
if (streakModifier > 0) {
modifier += streakModifier;
breakdown.streak = Math.round(baseXp * streakModifier);
}
}
let incompleteXp = 0;
if (incompleteTests !== undefined && incompleteTests.length > 0) {
incompleteTests.forEach((it: { acc: number; seconds: number }) => {
let modifier = (it.acc - 50) / 50;
if (modifier < 0) modifier = 0;
incompleteXp += Math.round(it.seconds * modifier);
});
breakdown.incomplete = incompleteXp;
} else if (incompleteTestSeconds && incompleteTestSeconds > 0) {
incompleteXp = Math.round(incompleteTestSeconds);
breakdown.incomplete = incompleteXp;
}
const accuracyModifier = (acc - 50) / 50;
let dailyBonus = 0;
if (isSafeNumber(lastResultTimestamp)) {
const lastResultDay = getStartOfDayTimestamp(lastResultTimestamp);
const today = getCurrentDayTimestamp();
if (lastResultDay !== today) {
const proportionalXp = Math.round(currentTotalXp * 0.05);
dailyBonus = Math.max(
Math.min(maxDailyBonus, proportionalXp),
minDailyBonus
);
breakdown.daily = dailyBonus;
}
}
const xpWithModifiers = Math.round(baseXp * modifier);
const xpAfterAccuracy = Math.round(xpWithModifiers * accuracyModifier);
breakdown.accPenalty = xpWithModifiers - xpAfterAccuracy;
const totalXp =
Math.round((xpAfterAccuracy + incompleteXp) * gainMultiplier) + dailyBonus;
if (gainMultiplier > 1) {
// breakdown.push([
// "configMultiplier",
// Math.round((xpAfterAccuracy + incompleteXp) * (gainMultiplier - 1)),
// ]);
breakdown.configMultiplier = gainMultiplier;
}
const isAwardingDailyBonus = dailyBonus > 0;
return {
xp: totalXp,
dailyBonus: isAwardingDailyBonus,
breakdown,
};
return new MonkeyResponse("Result added", {
isPb: false,
tagPbs: [],
insertedId: "",
xp: 0,
dailyXpBonus: false,
xpBreakdown: {},
streak: 0,
});
}

View file

@ -189,7 +189,7 @@ export function formatSeconds(
}
export function intersect<T>(a: T[], b: T[], removeDuplicates = false): T[] {
let t;
let t: T[];
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
if (b.length > a.length) (t = b), (b = a), (a = t); // indexOf to loop over shorter
const filtered = a.filter(function (e) {
@ -198,9 +198,8 @@ export function intersect<T>(a: T[], b: T[], removeDuplicates = false): T[] {
return removeDuplicates ? [...new Set(filtered)] : filtered;
}
export function escapeHTML(str: string): string {
return String(str).replace(/[^\w. ]/gi, function (c) {
return str.replace(/[^\w. ]/gi, function (c) {
return "&#" + c.charCodeAt(0) + ";";
});
}

View file

@ -557,10 +557,10 @@
<i class="fas fa-fw fa-ad"></i>
</button> -->
</div>
</div>
<div class="loginTip">
<a href="/login" router-link>Sign in</a>
to save your result
<div class="loginTip">
<a href="/login" router-link>Sign in</a>
to save your result
</div>
</div>
<div class="ssWatermark hidden">monkeytype.com</div>
</div>

View file

@ -645,6 +645,7 @@
"tribeResults tribeResults"
"stats chart"
"morestats morestats"
"bottom bottom"
"tribeBottom tribeBottom";
// "wordsHistory wordsHistory"
// "buttons buttons"
@ -780,6 +781,10 @@
-webkit-user-select: none;
}
.bottom {
grid-area: bottom;
}
.chart {
grid-area: chart;
width: 100%;

View file

@ -711,9 +711,17 @@
}
}
.pageTest #typingTest .tribeBars,
.pageTribe .tribeBars {
grid-area: players;
}
.pageTest #typingTest .tribeBars {
margin-bottom: 1em;
font-size: 1rem;
}
.pageTest #typingTest .tribeBars,
.pageTribe .tribeBars {
td {
padding: 0 0.5rem;
}

View file

@ -732,4 +732,16 @@ export const commandlineConfigMetadata: CommandlineConfigMetadataObject = {
options: "fromSchema",
},
},
//tribe
tribeDelta: {
subgroup: {
options: "fromSchema",
},
},
tribeCarets: {
subgroup: {
options: "fromSchema",
},
},
};

View file

@ -60,6 +60,12 @@ const adsCommand = buildCommandForConfigKey("ads");
const minSpeedCommand = buildCommandForConfigKey("minWpm");
const minAccCommand = buildCommandForConfigKey("minAcc");
const paceCaretCommand = buildCommandForConfigKey("paceCaret");
const modeCommand = buildCommandForConfigKey("mode");
const timeCommand = buildCommandForConfigKey("time");
const wordsCommand = buildCommandForConfigKey("words");
const quoteLengthCommand = buildCommandForConfigKey("quoteLength");
const punctuationCommand = buildCommandForConfigKey("punctuation");
const numbersCommand = buildCommandForConfigKey("numbers");
export const commands: CommandsSubgroup = {
title: "",
@ -68,12 +74,12 @@ export const commands: CommandsSubgroup = {
...ResultScreenCommands,
//test screen
buildCommandForConfigKey("punctuation"),
buildCommandForConfigKey("numbers"),
buildCommandForConfigKey("mode"),
buildCommandForConfigKey("time"),
buildCommandForConfigKey("words"),
buildCommandForConfigKey("quoteLength"),
punctuationCommand,
numbersCommand,
modeCommand,
timeCommand,
wordsCommand,
quoteLengthCommand,
languageCommand,
{
id: "changeCustomModeText",
@ -371,12 +377,12 @@ const lists = {
tags: TagsCommands[0]?.subgroup,
resultSaving: ResultSavingCommands[0]?.subgroup,
blindMode: blindModeCommand.subgroup,
mode: ModeCommands[0]?.subgroup,
time: TimeCommands[0]?.subgroup,
words: WordsCommands[0]?.subgroup,
quoteLength: QuoteLengthCommands[0]?.subgroup,
punctuation: PunctuationCommands[0]?.subgroup,
numbers: NumbersCommands[0]?.subgroup,
mode: modeCommand.subgroup,
time: timeCommand.subgroup,
words: wordsCommand.subgroup,
quoteLength: quoteLengthCommand.subgroup,
punctuation: punctuationCommand.subgroup,
numbers: numbersCommand.subgroup,
};
export function doesListExist(listName: string): boolean {

View file

@ -210,17 +210,29 @@ export function genericSet<T extends keyof ConfigSchemas.Config>(
}
//numbers
export function setNumbers(numb: boolean, nosave?: boolean): boolean {
return genericSet("numbers", numb, nosave);
export function setNumbers(
numb: boolean,
nosave?: boolean,
tribeOverride = false
): boolean {
return genericSet("numbers", numb, nosave, tribeOverride);
}
//punctuation
export function setPunctuation(punc: boolean, nosave?: boolean): boolean {
return genericSet("punctuation", punc, nosave);
export function setPunctuation(
punc: boolean,
nosave?: boolean,
tribeOverride = false
): boolean {
return genericSet("punctuation", punc, nosave, tribeOverride);
}
export function setMode(mode: Mode, nosave?: boolean): boolean {
return genericSet("mode", mode, nosave);
export function setMode(
mode: Mode,
nosave?: boolean,
tribeOverride = false
): boolean {
return genericSet("mode", mode, nosave, tribeOverride);
}
export function setPlaySoundOnError(

View file

@ -1008,12 +1008,12 @@ $(document).on("keydown", async (event) => {
if (TribeState.getState() >= 5) {
if (TribeState.getState() > 5 && TribeState.getState() < 21) return;
if (TribeState.getState() === 5 && ActivePage.get() !== "tribe") {
navigate("/tribe");
void navigate("/tribe");
return;
}
} else {
if (ActivePage.get() !== "test") {
navigate("/");
void navigate("/");
return;
}
}
@ -1061,10 +1061,10 @@ $(document).on("keydown", async (event) => {
event,
});
} else {
handleChar("\n", TestInput.input.current.length);
void handleChar("\n", TestInput.input.current.length);
setWordsInput(" " + TestInput.input.current);
if (Config.tapeMode !== "off") {
TestUI.scrollTape();
void TestUI.scrollTape();
}
}
}
@ -1093,12 +1093,12 @@ $(document).on("keydown", async (event) => {
if (TribeState.getState() >= 5) {
if (TribeState.getState() > 5 && TribeState.getState() < 21) return;
if (TribeState.getState() === 5 && ActivePage.get() !== "tribe") {
navigate("/tribe");
void navigate("/tribe");
return;
}
} else {
if (ActivePage.get() !== "test") {
navigate("/");
void navigate("/");
return;
}
}
@ -1146,10 +1146,10 @@ $(document).on("keydown", async (event) => {
event,
});
} else {
handleChar("\n", TestInput.input.current.length);
void handleChar("\n", TestInput.input.current.length);
setWordsInput(" " + TestInput.input.current);
if (Config.tapeMode !== "off") {
TestUI.scrollTape();
void TestUI.scrollTape();
}
}
}

View file

@ -7,8 +7,9 @@ import { isFunboxActive } from "../test/funbox/list";
import * as TestState from "../test/test-state";
import * as Notifications from "../elements/notifications";
import tribeSocket from "../tribe/tribe-socket";
import { setAutoJoin } from "../tribe/tribe";
import { setAutoJoin } from "../tribe/tribe-auto-join";
import { LoadingOptions } from "../pages/page";
import { setNavigationService } from "../observables/navigate-event";
//source: https://www.youtube.com/watch?v=OstALBk-jTc
// https://www.youtube.com/watch?v=OstALBk-jTc
@ -310,3 +311,11 @@ document.addEventListener("DOMContentLoaded", () => {
}
});
});
// Register navigation service for modules that can't directly import navigate
// due to circular dependency constraints
setNavigationService({
navigate(url, options) {
void navigate(url, options);
},
});

View file

@ -1,8 +1,8 @@
import * as TribeTypes from "../tribe/types";
export class InputSuggestions {
private inputElement: JQuery<HTMLElement>;
private suggestionsElement: JQuery<HTMLElement> | undefined;
private inputElement: JQuery;
private suggestionsElement: JQuery | undefined;
private maxSuggestions: number;
private selectedIndex: number | undefined;
private prefix: string;
@ -14,7 +14,7 @@ export class InputSuggestions {
private applyWith: string[];
constructor(
inputElement: JQuery<HTMLElement>,
inputElement: JQuery,
prefix: string,
suffix: string,
maxSuggestions: number,

View file

@ -0,0 +1,25 @@
type NavigateOptions = {
force?: boolean;
tribeOverride?: boolean;
};
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export interface NavigationService {
navigate(url: string, options?: NavigateOptions): void;
}
let service: NavigationService | undefined;
export function setNavigationService(s: NavigationService): void {
if (service !== undefined) {
throw new Error("NavigationService already initialized");
}
service = s;
}
export function navigate(url: string, options?: NavigateOptions): void {
if (service === undefined) {
throw new Error("NavigationService not initialized");
}
service.navigate(url, options);
}

View file

@ -17,11 +17,10 @@ export const page = new Page({
beforeHide: async (): Promise<void> => {
$("#wordsInput").trigger("focusout");
},
afterHide: async (options): Promise<void> => {
afterHide: async (): Promise<void> => {
ManualRestart.set();
TestLogic.restart({
noAnim: true,
tribeOverride: options.tribeOverride ?? false,
});
void Funbox.clear();
void ModesNotice.update();

View file

@ -4,7 +4,7 @@ import * as TribeState from "../tribe/tribe-state";
import * as TribeChat from "../tribe/tribe-chat";
export const page = new Page({
name: "tribe",
id: "tribe",
element: $(".page.pageTribe"),
path: "/tribe",
beforeHide: async () => {

View file

@ -56,7 +56,7 @@ export function show(): void {
.css("opacity", 0)
.removeClass("hidden")
.animate({ opacity: 1 }, 125, () => {
$("#tribeBrowsePublicRoomsPopup .search").focus();
$("#tribeBrowsePublicRoomsPopup .search").trigger("focus");
$("#tribeBrowsePublicRoomsPopup .search").val("");
});
}

View file

@ -8,7 +8,7 @@ export function show(): void {
.css("opacity", 0)
.removeClass("hidden")
.animate({ opacity: 1 }, 125, () => {
$("#tribeRoomCodePopup input").focus();
$("#tribeRoomCodePopup input").trigger("focus");
$("#tribeRoomCodePopup input").val("");
});
}

View file

@ -807,7 +807,7 @@ export async function retrySavingResult(): Promise<void> {
const tribeChartData = {
wpm: [...(completedEvent.chartData as ChartData).wpm],
raw: [...(completedEvent.chartData as ChartData).raw],
burst: [...(completedEvent.chartData as ChartData).burst],
err: [...(completedEvent.chartData as ChartData).err],
};
@ -1302,7 +1302,7 @@ export async function finish(difficultyFailed = false): Promise<void> {
const tribeChartData = {
wpm: [...(completedEvent.chartData as ChartData).wpm],
raw: [...(completedEvent.chartData as ChartData).raw],
burst: [...(completedEvent.chartData as ChartData).burst],
err: [...(completedEvent.chartData as ChartData).err],
};

View file

@ -14,6 +14,7 @@ export let isLanguageRightToLeft = false;
export let isDirectionReversed = false;
export let testRestarting = false;
export let resultVisible = false;
export let removedUIWordCount = 0;
export function setRepeated(tf: boolean): void {
isRepeated = tf;
@ -85,3 +86,11 @@ export function setTestRestarting(val: boolean): void {
export function setResultVisible(val: boolean): void {
resultVisible = val;
}
export function setRemovedUIWordCount(val: number): void {
removedUIWordCount = val;
}
export function incrementRemovedUIWordCount(): void {
removedUIWordCount++;
}

View file

@ -194,7 +194,9 @@ export function updateRoomConfig(): void {
$(".pageTribe .tribePage.lobby .currentConfig .groups").append(`
<div class='group' aria-label="Funbox" data-balloon-pos="up" commands="funbox">
<i class="fas fa-gamepad"></i>${room.config.funbox.replace(/_/g, " ")}
<i class="fas fa-gamepad"></i>${
room.config.funbox.join(", ").replace(/_/g, " ") || "none"
}
</div>
`);
@ -264,29 +266,27 @@ export function init(): void {
TribeConfig.apply(room.config);
}
$(".pageTribe .tribePage.lobby .inviteLink .text").hover(
function () {
$(".pageTribe .tribePage.lobby .inviteLink .text")
.on("mouseenter", function () {
$(this).css(
"color",
"#" + $(".pageTribe .tribePage.lobby .inviteLink .text").text()
);
},
function () {
})
.on("mouseleave", function () {
$(this).css("color", "");
}
);
});
$(".pageTest #result #tribeResultBottom .inviteLink .text").hover(
function () {
$(".pageTest #result #tribeResultBottom .inviteLink .text")
.on("mouseenter", function () {
$(this).css(
"color",
"#" + $(".pageTest #result #tribeResultBottom .inviteLink .text").text()
);
},
function () {
})
.on("mouseleave", function () {
$(this).css("color", "");
}
);
});
$(
".pageTribe .tribePage.lobby .inviteLink .text, .pageTest #result #tribeResultBottom .inviteLink .text"
@ -297,7 +297,7 @@ $(
);
Notifications.add("Code copied", 1);
} catch (e) {
Notifications.add("Could not copy to clipboard: " + e, -1);
Notifications.add("Could not copy to clipboard: " + String(e), -1);
}
});
@ -310,7 +310,7 @@ $(
);
Notifications.add("Link copied", 1);
} catch (e) {
Notifications.add("Could not copy to clipboard: " + e, -1);
Notifications.add("Could not copy to clipboard: " + String(e), -1);
}
});

View file

@ -0,0 +1,13 @@
let autoJoin: string | undefined = undefined;
export function setAutoJoin(code: string): void {
autoJoin = code;
}
export function getAutoJoin(): string | undefined {
return autoJoin;
}
export function clearAutoJoin(): void {
autoJoin = undefined;
}

View file

@ -5,7 +5,7 @@ import tribeSocket from "./tribe-socket";
import * as ThemeColors from "../elements/theme-colors";
export function init(page: string): void {
let el: JQuery<HTMLElement> | undefined;
let el: JQuery | undefined;
if (page === "test") {
el = $(".pageTest #typingTest .tribeBars");
@ -100,7 +100,7 @@ export function update(page: string, userId: string): void {
update("tribe", userId);
return;
}
let el: JQuery<HTMLElement> | undefined;
let el: JQuery | undefined;
if (page === "test") {
el = $(".pageTest #typingTest .tribeBars");
} else if (page === "tribe") {
@ -138,7 +138,7 @@ export function completeBar(page: string, userId: string): void {
completeBar("tribe", userId);
return;
}
let el: JQuery<HTMLElement> | undefined;
let el: JQuery | undefined;
if (page === "test") {
el = $(".pageTest #typingTest .tribeBars");
} else if (page === "tribe") {
@ -169,7 +169,7 @@ export function fadeUser(
fadeUser("tribe", userId, changeColor);
return;
}
let el: JQuery<HTMLElement> | undefined;
let el: JQuery | undefined;
if (page === "test") {
el = $(".pageTest #typingTest .tribeBars");
} else if (page === "tribe") {

View file

@ -13,7 +13,7 @@ import * as TribeTypes from "./types";
const carets: { [key: string]: TribeCaret } = {};
export class TribeCaret {
private element: JQuery<HTMLElement> | undefined;
private element: JQuery | undefined;
constructor(
private socketId: string,
@ -138,8 +138,7 @@ export class TribeCaret {
caretWidth === undefined
) {
//todo fix
// oxlint-disable-next-line @typescript-eslint/only-throw-error
// eslint-disable-next-line no-throw-literal
// eslint-disable-next-line @typescript-eslint/only-throw-error, no-throw-literal
throw ``;
}

View file

@ -270,7 +270,7 @@ async function fillData(chart: Chart, userId: string): Promise<void> {
// Math.max(...result.chartData.raw)
// );
const smoothedRawData = smooth(result.chartData.raw, 1);
const smoothedRawData = smooth(result.chartData.burst, 1);
chart.data.labels = labels;
//@ts-expect-error tribe
@ -351,7 +351,7 @@ export async function updateChartMaxValues(): Promise<void> {
const result = room.users[userId]?.result;
if (!result) continue;
const maxUserWpm = Math.max(maxWpm, Math.max(...result.chartData.wpm));
const maxUserRaw = Math.max(maxRaw, Math.max(...result.chartData.raw));
const maxUserRaw = Math.max(maxRaw, Math.max(...result.chartData.burst));
if (maxUserWpm > maxWpm) {
maxWpm = maxUserWpm;
}

View file

@ -1,7 +1,7 @@
import * as Notifications from "../elements/notifications";
import * as TribeState from "../tribe/tribe-state";
import * as Misc from "../utils/misc";
import * as TestUI from "../test/test-ui";
import * as TestState from "../test/test-state";
import tribeSocket from "./tribe-socket";
import { InputSuggestions } from "../elements/input-suggestions";
import { getEmojiList } from "../utils/json-data";
@ -331,21 +331,23 @@ $(".pageTest #result #tribeResultBottom .chat .input input").on(
}
);
$(document).keydown((e) => {
$(document).on("keydown", (e) => {
if (TribeState.getState() === 5) {
if (
e.key === "/" &&
!$(".pageTribe .lobby .chat .input input").is(":focus")
) {
$(".pageTribe .lobby .chat .input input").focus();
$(".pageTribe .lobby .chat .input input").trigger("focus");
e.preventDefault();
}
} else if (TestUI.resultVisible && TribeState.getState() >= 20) {
} else if (TestState.resultVisible && TribeState.getState() >= 20) {
if (
e.key === "/" &&
!$(".pageTest #result #tribeResultBottom .chat .input input").is(":focus")
) {
$(".pageTest #result #tribeResultBottom .chat .input input").focus();
$(".pageTest #result #tribeResultBottom .chat .input input").trigger(
"focus"
);
e.preventDefault();
}
}

View file

@ -7,12 +7,13 @@ import * as TribeButtons from "./tribe-buttons";
import * as TribeState from "../tribe/tribe-state";
import tribeSocket from "./tribe-socket";
import * as TribeTypes from "./types";
import { Difficulty, Mode } from "@monkeytype/contracts/schemas/shared";
import { Difficulty, Mode } from "@monkeytype/schemas/shared";
import {
FunboxName,
QuoteLength,
QuoteLengthConfig,
StopOnError,
} from "@monkeytype/contracts/schemas/configs";
} from "@monkeytype/schemas/configs";
import { Language } from "@monkeytype/schemas/languages";
export function getArray(config: TribeTypes.RoomConfig): string[] {
const ret: string[] = [];
@ -72,7 +73,7 @@ export function apply(config: TribeTypes.RoomConfig): void {
} else if (config.mode === "words") {
UpdateConfig.setWordCount(config.mode2 as number, true, true);
} else if (config.mode === "quote") {
UpdateConfig.setQuoteLength(config.mode2 as QuoteLength, true, true, true);
UpdateConfig.setQuoteLength(config.mode2 as QuoteLengthConfig, true, true);
} else if (config.mode === "custom") {
//todo fix
// CustomText.setText(config.customText.text, true);
@ -82,7 +83,7 @@ export function apply(config: TribeTypes.RoomConfig): void {
// CustomText.setWord(config.customText.word, true);
}
UpdateConfig.setDifficulty(config.difficulty as Difficulty, true, true);
UpdateConfig.setLanguage(config.language, true, true);
UpdateConfig.setLanguage(config.language as Language, true, true);
UpdateConfig.setPunctuation(config.punctuation, true, true);
UpdateConfig.setNumbers(config.numbers, true, true);
Funbox.setFunbox(config.funbox as FunboxName[], true);

View file

@ -15,9 +15,13 @@ export async function change(
if (transition) return;
transition = true;
const activePage = $(".page.pageTribe .tribePage.active");
const activePageEl = activePage[0] as HTMLElement;
const newPageEl = document.querySelector(
`.page.pageTribe .tribePage.${page}`
) as HTMLElement;
void swapElements(
activePage,
$(`.page.pageTribe .tribePage.${page}`),
activePageEl,
newPageEl,
250,
async () => {
active = page;

View file

@ -109,7 +109,7 @@ export function updateBar(
Config.mode === "time"
? user.progress?.wpmProgress + "%"
: user.progress?.progress + "%";
if (percentOverride) {
if (percentOverride !== undefined && percentOverride !== 0) {
percent = percentOverride + "%";
}
el.stop(true, false).animate(
@ -161,7 +161,7 @@ export function updatePositions(
//todo once i use state and redraw elements as needed instead of always keeping elements in the dom
//reorder table rows based on the ordered list
if (reorder) {
const elements: Record<string, JQuery<HTMLElement>> = {};
const elements: Record<string, JQuery> = {};
const el = $(".pageTest #result #tribeResults table tbody");
el.find("tr.user").each((_, userEl) => {
const id = $(userEl).attr("id");
@ -175,13 +175,13 @@ export function updatePositions(
for (const [_pos, users] of Object.entries(positions)) {
for (const user of users) {
el.append(elements[user.id] as JQuery<HTMLElement>);
el.append(elements[user.id] as JQuery);
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete elements[user.id];
}
}
for (const id of Object.keys(elements)) {
el.append(elements[id] as JQuery<HTMLElement>);
el.append(elements[id] as JQuery);
}
}
}

View file

@ -1,6 +1,6 @@
import { Mode } from "@monkeytype/contracts/schemas/shared";
import { Mode } from "@monkeytype/schemas/shared";
import Socket from "../socket";
import { QuoteLength } from "@monkeytype/contracts/schemas/configs";
import { QuoteLength } from "@monkeytype/schemas/configs";
import * as TribeTypes from "../../types";
type GetPublicRoomsResponse = {

View file

@ -29,20 +29,15 @@ import * as TestStats from "../test/test-stats";
import * as TestInput from "../test/test-input";
import * as TribeCarets from "./tribe-carets";
import * as TribeTypes from "./types";
import { navigate } from "../controllers/route-controller";
import { navigate } from "../observables/navigate-event";
import { ColorName } from "../elements/theme-colors";
import * as TribeAutoJoin from "./tribe-auto-join";
const defaultName = "Guest";
let name = "Guest";
export const expectedVersion = "0.13.5";
let autoJoin: string | undefined = undefined;
export function setAutoJoin(code: string): void {
autoJoin = code;
}
export function getStateString(state: number): string {
if (state === -1) return "error";
if (state === 1) return "connected";
@ -220,11 +215,12 @@ async function connect(): Promise<void> {
UpdateConfig.setTimerStyle("mini", true);
TribePageMenu.enableButtons();
updateState(1);
if (autoJoin !== undefined) {
TribePagePreloader.updateText(`Joining room ${autoJoin}`);
const autoJoinCode = TribeAutoJoin.getAutoJoin();
if (autoJoinCode !== undefined) {
TribePagePreloader.updateText(`Joining room ${autoJoinCode}`);
TribePagePreloader.updateSubtext("Please wait...");
setTimeout(() => {
joinRoom(autoJoin as string);
joinRoom(autoJoinCode);
}, 500);
} else {
void TribePages.change("menu");
@ -296,7 +292,7 @@ TribeSocket.in.system.disconnect((reason, details) => {
void reset();
if (roomId !== undefined) {
autoJoin = roomId;
TribeAutoJoin.setAutoJoin(roomId);
}
});

View file

@ -1,4 +1,4 @@
import { ChartData } from "@monkeytype/contracts/schemas/results";
import { ChartData } from "@monkeytype/schemas/results";
export type SystemStats = {
pingStart: number;

View file

@ -173,9 +173,9 @@ export function escapeHTML<T extends string | null | undefined>(str: T): T {
if (str === null || str === undefined) {
return str;
}
return String(str).replace(/[^\w. ]/gi, function (c) {
return str.replace(/[^\w. ]/gi, function (c) {
return "&#" + c.charCodeAt(0) + ";";
});
}) as T;
}
export function isUsernameValid(name: string): boolean {

View file

@ -11,7 +11,7 @@ import * as Loader from "../elements/loader";
import * as AccountButton from "../elements/account-button";
import { restart as restartTest } from "../test/test-logic";
import * as ChallengeController from "../controllers/challenge-controller";
import * as Tribe from "../tribe/tribe";
import { setAutoJoin } from "../tribe/tribe-auto-join";
import {
DifficultySchema,
Mode2Schema,
@ -315,7 +315,7 @@ export function loadTribeAutoJoinFromUrl(override?: string): void {
if (window.location.pathname !== "/tribe") return;
const getValue = Misc.findGetParameter("code", override);
if (getValue === null) return;
Tribe.setAutoJoin(getValue);
setAutoJoin(getValue);
}
AuthEvent.subscribe((event) => {