feat: add friend requests and list (@fehmer) (#6658)

make some friends on monkeytype

---------

Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
Christian Fehmer 2025-10-28 12:36:16 +01:00 committed by GitHub
parent 9aa30a2a41
commit d885e70232
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 3335 additions and 115 deletions

View file

@ -0,0 +1,365 @@
import {
describe,
it,
expect,
vi,
beforeAll,
beforeEach,
afterEach,
} from "vitest";
import { ObjectId } from "mongodb";
import * as ConnectionsDal from "../../../src/dal/connections";
import { createConnection } from "../../__testData__/connections";
describe("ConnectionsDal", () => {
beforeAll(async () => {
await ConnectionsDal.createIndicies();
});
describe("getRequests", () => {
it("get by uid", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const initOne = await createConnection({ initiatorUid: uid });
const initTwo = await createConnection({ initiatorUid: uid });
const friendOne = await createConnection({ receiverUid: uid });
const _decoy = await createConnection({});
//WHEN / THEM
expect(
await ConnectionsDal.getConnections({
initiatorUid: uid,
receiverUid: uid,
})
).toStrictEqual([initOne, initTwo, friendOne]);
});
it("get by uid and status", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const initAccepted = await createConnection({
initiatorUid: uid,
status: "accepted",
});
const _initPending = await createConnection({
initiatorUid: uid,
status: "pending",
});
const initBlocked = await createConnection({
initiatorUid: uid,
status: "blocked",
});
const friendAccepted = await createConnection({
receiverUid: uid,
status: "accepted",
});
const _friendPending = await createConnection({
receiverUid: uid,
status: "pending",
});
const _decoy = await createConnection({ status: "accepted" });
//WHEN / THEN
expect(
await ConnectionsDal.getConnections({
initiatorUid: uid,
receiverUid: uid,
status: ["accepted", "blocked"],
})
).toStrictEqual([initAccepted, initBlocked, friendAccepted]);
});
});
describe("create", () => {
const now = 1715082588;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(now);
});
afterEach(() => {
vi.useRealTimers();
});
it("should fail creating duplicates", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = await createConnection({
initiatorUid: uid,
});
//WHEN/THEN
await expect(
createConnection({
initiatorUid: first.receiverUid,
receiverUid: uid,
})
).rejects.toThrow("Connection request already sent");
});
it("should create", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const receiverUid = new ObjectId().toHexString();
//WHEN
const created = await ConnectionsDal.create(
{ uid, name: "Bob" },
{ uid: receiverUid, name: "Kevin" },
2
);
//THEN
expect(created).toEqual({
_id: created._id,
initiatorUid: uid,
initiatorName: "Bob",
receiverUid: receiverUid,
receiverName: "Kevin",
lastModified: now,
status: "pending",
key: `${uid}/${receiverUid}`,
});
});
it("should fail if maximum connections are reached", async () => {
//GIVEN
const initiatorUid = new ObjectId().toHexString();
await createConnection({ initiatorUid });
await createConnection({ initiatorUid });
//WHEN / THEM
await expect(createConnection({ initiatorUid }, 2)).rejects.toThrow(
"Maximum number of connections reached\nStack: create connection request"
);
});
it("should fail creating if blocked", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = await createConnection({
initiatorUid: uid,
status: "blocked",
});
//WHEN/THEN
await expect(
createConnection({
initiatorUid: first.receiverUid,
receiverUid: uid,
})
).rejects.toThrow("Connection blocked");
});
});
describe("updateStatus", () => {
const now = 1715082588;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(now);
});
afterEach(() => {
vi.useRealTimers();
});
it("should update the status", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = await createConnection({
receiverUid: uid,
lastModified: 100,
});
const second = await createConnection({
receiverUid: uid,
lastModified: 200,
});
//WHEN
await ConnectionsDal.updateStatus(
uid,
first._id.toHexString(),
"accepted"
);
//THEN
expect(await ConnectionsDal.getConnections({ receiverUid: uid })).toEqual(
[{ ...first, status: "accepted", lastModified: now }, second]
);
//can update twice to the same status
await ConnectionsDal.updateStatus(
uid,
first._id.toHexString(),
"accepted"
);
});
it("should fail if uid does not match the reeceiverUid", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = await createConnection({
initiatorUid: uid,
});
//WHEN / THEN
await expect(
ConnectionsDal.updateStatus(uid, first._id.toHexString(), "accepted")
).rejects.toThrow("No permission or connection not found");
});
});
describe("deleteById", () => {
it("should delete by initiator", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = await createConnection({
initiatorUid: uid,
});
const second = await createConnection({
initiatorUid: uid,
});
//WHEN
await ConnectionsDal.deleteById(uid, first._id.toHexString());
//THEN
expect(
await ConnectionsDal.getConnections({ initiatorUid: uid })
).toStrictEqual([second]);
});
it("should delete by receiver", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = await createConnection({
receiverUid: uid,
});
const second = await createConnection({
receiverUid: uid,
status: "accepted",
});
//WHEN
await ConnectionsDal.deleteById(uid, first._id.toHexString());
//THEN
expect(
await ConnectionsDal.getConnections({
initiatorUid: second.initiatorUid,
})
).toStrictEqual([second]);
});
it("should fail if uid does not match", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const first = await createConnection({
initiatorUid: uid,
});
//WHEN / THEN
await expect(
ConnectionsDal.deleteById("Bob", first._id.toHexString())
).rejects.toThrow("No permission or connection not found");
});
it("should fail if initiator deletes blocked by receiver", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const myRequestWasBlocked = await createConnection({
initiatorName: uid,
status: "blocked",
});
//WHEN / THEN
await expect(
ConnectionsDal.deleteById(uid, myRequestWasBlocked._id.toHexString())
).rejects.toThrow("No permission or connection not found");
});
it("allow receiver to delete blocked", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const myBlockedUser = await createConnection({
receiverUid: uid,
status: "blocked",
});
//WHEN
await ConnectionsDal.deleteById(uid, myBlockedUser._id.toHexString());
//THEN
expect(await ConnectionsDal.getConnections({ receiverUid: uid })).toEqual(
[]
);
});
});
describe("deleteByUid", () => {
it("should delete by uid", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const _initOne = await createConnection({ initiatorUid: uid });
const _initTwo = await createConnection({ initiatorUid: uid });
const _friendOne = await createConnection({ receiverUid: uid });
const decoy = await createConnection({});
//WHEN
await ConnectionsDal.deleteByUid(uid);
//THEN
expect(
await ConnectionsDal.getConnections({
initiatorUid: uid,
receiverUid: uid,
})
).toEqual([]);
expect(
await ConnectionsDal.getConnections({
initiatorUid: decoy.initiatorUid,
})
).toEqual([decoy]);
});
});
describe("updateName", () => {
it("should update the name", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
const initOne = await createConnection({
initiatorUid: uid,
initiatorName: "Bob",
});
const initTwo = await createConnection({
initiatorUid: uid,
initiatorName: "Bob",
});
const friendOne = await createConnection({
receiverUid: uid,
receiverName: "Bob",
});
const decoy = await createConnection({});
//WHEN
await ConnectionsDal.updateName(uid, "King Bob");
//THEN
expect(
await ConnectionsDal.getConnections({
initiatorUid: uid,
receiverUid: uid,
})
).toEqual([
{ ...initOne, initiatorName: "King Bob" },
{ ...initTwo, initiatorName: "King Bob" },
{ ...friendOne, receiverName: "King Bob" },
]);
expect(
await ConnectionsDal.getConnections({
initiatorUid: decoy.initiatorUid,
})
).toEqual([decoy]);
});
});
});

View file

@ -382,7 +382,7 @@ describe("PresetDal", () => {
).presetId;
//WHEN
PresetDal.removePreset(uid, first);
await PresetDal.removePreset(uid, first);
//THEN
const read = await PresetDal.getPresets(uid);

View file

@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import _ from "lodash";
import * as UserDAL from "../../../src/dal/user";
import * as UserTestData from "../../__testData__/users";
import { createConnection as createFriend } from "../../__testData__/connections";
import { ObjectId } from "mongodb";
import { MonkeyMail, ResultFilters } from "@monkeytype/schemas/users";
import { PersonalBest, PersonalBests } from "@monkeytype/schemas/shared";
@ -1980,4 +1981,121 @@ describe("UserDal", () => {
expect(read.streak?.hourOffset).toBeUndefined();
});
});
describe("getFriends", () => {
it("get list of friends", async () => {
//GIVEN
const me = await UserTestData.createUser({ name: "Me" });
const uid = me.uid;
const friendOne = await UserTestData.createUser({
name: "One",
personalBests: {
time: {
"15": [UserTestData.pb(100)],
"60": [UserTestData.pb(85), UserTestData.pb(90)],
},
} as any,
inventory: {
badges: [{ id: 42, selected: true }, { id: 23 }, { id: 5 }],
},
banned: true,
lbOptOut: true,
premium: { expirationTimestamp: -1 } as any,
});
const friendOneRequest = await createFriend({
initiatorUid: uid,
receiverUid: friendOne.uid,
status: "accepted",
lastModified: 100,
});
const friendTwo = await UserTestData.createUser({
name: "Two",
discordId: "discordId",
discordAvatar: "discordAvatar",
timeTyping: 600,
startedTests: 150,
completedTests: 125,
streak: {
length: 10,
maxLength: 50,
lastResultTimestamp: 0,
hourOffset: -1,
} as any,
xp: 42,
inventory: {
badges: [{ id: 23 }, { id: 5 }],
},
premium: {
expirationTimestamp: vi.getRealSystemTime() + 5000,
} as any,
});
const friendTwoRequest = await createFriend({
initiatorUid: uid,
receiverUid: friendTwo.uid,
status: "accepted",
lastModified: 200,
});
const friendThree = await UserTestData.createUser({ name: "Three" });
const friendThreeRequest = await createFriend({
receiverUid: uid,
initiatorUid: friendThree.uid,
status: "accepted",
lastModified: 300,
});
//non accepted
await createFriend({ receiverUid: uid, status: "pending" });
await createFriend({ initiatorUid: uid, status: "blocked" });
//WHEN
const friends = await UserDAL.getFriends(uid);
//THEN
expect(friends).toEqual([
{
uid: friendOne.uid,
name: "One",
lastModified: 100,
connectionId: friendOneRequest._id,
// oxlint-disable-next-line no-non-null-assertion
top15: friendOne.personalBests.time["15"]![0] as any,
// oxlint-disable-next-line no-non-null-assertion
top60: friendOne.personalBests.time["60"]![1] as any,
badgeId: 42,
banned: true,
lbOptOut: true,
isPremium: true,
},
{
uid: friendTwo.uid,
name: "Two",
lastModified: 200,
connectionId: friendTwoRequest._id,
discordId: friendTwo.discordId,
discordAvatar: friendTwo.discordAvatar,
timeTyping: friendTwo.timeTyping,
startedTests: friendTwo.startedTests,
completedTests: friendTwo.completedTests,
streak: {
length: friendTwo.streak?.length,
maxLength: friendTwo.streak?.maxLength,
},
xp: friendTwo.xp,
isPremium: true,
},
{
uid: friendThree.uid,
name: "Three",
lastModified: 300,
connectionId: friendThreeRequest._id,
},
{
uid: me.uid,
name: "Me",
},
]);
});
});
});

View file

@ -24,6 +24,9 @@ beforeAll(async () => {
}));
setupCommonMocks();
//we compare the time in mongodb to calculate premium status, so we have to use real time here
vi.useRealTimers();
});
afterEach(async () => {

View file

@ -0,0 +1,24 @@
import { ObjectId } from "mongodb";
import * as ConnectionsDal from "../../src/dal/connections";
export async function createConnection(
data: Partial<ConnectionsDal.DBConnection>,
maxPerUser = 25
): Promise<ConnectionsDal.DBConnection> {
const result = await ConnectionsDal.create(
{
uid: data.initiatorUid ?? new ObjectId().toHexString(),
name: data.initiatorName ?? "user" + new ObjectId().toHexString(),
},
{
uid: data.receiverUid ?? new ObjectId().toHexString(),
name: data.receiverName ?? "user" + new ObjectId().toHexString(),
},
maxPerUser
);
await ConnectionsDal.getCollection().updateOne(
{ _id: result._id },
{ $set: data }
);
return { ...result, ...data };
}

View file

@ -0,0 +1,398 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import request, { Test as SuperTest } from "supertest";
import app from "../../../src/app";
import { mockBearerAuthentication } from "../../__testData__/auth";
import * as Configuration from "../../../src/init/configuration";
import { ObjectId } from "mongodb";
import _ from "lodash";
import * as ConnectionsDal from "../../../src/dal/connections";
import * as UserDal from "../../../src/dal/user";
const mockApp = request(app);
const configuration = Configuration.getCachedConfiguration();
const uid = new ObjectId().toHexString();
const mockAuth = mockBearerAuthentication(uid);
describe("ConnectionsController", () => {
beforeEach(async () => {
await enableConnectionsEndpoints(true);
vi.useFakeTimers();
vi.setSystemTime(1000);
mockAuth.beforeEach();
});
describe("get connections", () => {
const getConnectionsMock = vi.spyOn(ConnectionsDal, "getConnections");
beforeEach(() => {
getConnectionsMock.mockClear();
});
it("should get for the current user", async () => {
//GIVEN
const friend: ConnectionsDal.DBConnection = {
_id: new ObjectId(),
lastModified: 42,
initiatorUid: new ObjectId().toHexString(),
initiatorName: "Bob",
receiverUid: new ObjectId().toHexString(),
receiverName: "Kevin",
status: "pending",
key: "key",
};
getConnectionsMock.mockResolvedValue([friend]);
//WHEN
const { body } = await mockApp
.get("/connections")
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(body.data).toEqual([
{ ...friend, _id: friend._id.toHexString(), key: undefined },
]);
expect(getConnectionsMock).toHaveBeenCalledWith({
initiatorUid: uid,
receiverUid: uid,
});
});
it("should filter by status", async () => {
//GIVEN
getConnectionsMock.mockResolvedValue([]);
//WHEN
await mockApp
.get("/connections")
.query({ status: "accepted" })
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(getConnectionsMock).toHaveBeenCalledWith({
initiatorUid: uid,
receiverUid: uid,
status: ["accepted"],
});
});
it("should filter by multiple status", async () => {
//GIVEN
getConnectionsMock.mockResolvedValue([]);
//WHEN
await mockApp
.get("/connections")
.query({ status: ["accepted", "blocked"] })
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(getConnectionsMock).toHaveBeenCalledWith({
initiatorUid: uid,
receiverUid: uid,
status: ["accepted", "blocked"],
});
});
it("should filter by type incoming", async () => {
//GIVEN
getConnectionsMock.mockResolvedValue([]);
//WHEN
await mockApp
.get("/connections")
.query({ type: "incoming" })
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(getConnectionsMock).toHaveBeenCalledWith({
receiverUid: uid,
});
});
it("should filter by type outgoing", async () => {
//GIVEN
getConnectionsMock.mockResolvedValue([]);
//WHEN
await mockApp
.get("/connections")
.query({ type: "outgoing" })
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(getConnectionsMock).toHaveBeenCalledWith({
initiatorUid: uid,
});
});
it("should filter by multiple types", async () => {
//GIVEN
getConnectionsMock.mockResolvedValue([]);
//WHEN
await mockApp
.get("/connections")
.query({ type: ["incoming", "outgoing"] })
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(getConnectionsMock).toHaveBeenCalledWith({
initiatorUid: uid,
receiverUid: uid,
});
});
it("should fail if connections endpoints are disabled", async () => {
await expectFailForDisabledEndpoint(
mockApp.get("/connections").set("Authorization", `Bearer ${uid}`)
);
});
it("should fail without authentication", async () => {
await mockApp.get("/connections").expect(401);
});
it("should fail for unknown query parameter", async () => {
const { body } = await mockApp
.get("/connections")
.query({ extra: "yes" })
.set("Authorization", `Bearer ${uid}`)
.expect(422);
expect(body).toStrictEqual({
message: "Invalid query schema",
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
});
});
});
describe("create connection", () => {
const getUserByNameMock = vi.spyOn(UserDal, "getUserByName");
const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser");
const createUserMock = vi.spyOn(ConnectionsDal, "create");
beforeEach(() => {
[getUserByNameMock, getPartialUserMock, createUserMock].forEach((it) =>
it.mockClear()
);
});
it("should create", async () => {
//GIVEN
const me = { uid, name: "Bob" };
const myFriend = { uid: new ObjectId().toHexString(), name: "Kevin" };
getUserByNameMock.mockResolvedValue(myFriend as any);
getPartialUserMock.mockResolvedValue(me as any);
const result: ConnectionsDal.DBConnection = {
_id: new ObjectId(),
lastModified: 42,
initiatorUid: me.uid,
initiatorName: me.name,
receiverUid: myFriend.uid,
receiverName: myFriend.name,
key: "test",
status: "pending",
};
createUserMock.mockResolvedValue(result);
//WHEN
const { body } = await mockApp
.post("/connections")
.send({ receiverName: "Kevin" })
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(body.data).toEqual({
_id: result._id.toHexString(),
lastModified: 42,
initiatorUid: me.uid,
initiatorName: me.name,
receiverUid: myFriend.uid,
receiverName: myFriend.name,
status: "pending",
});
expect(getUserByNameMock).toHaveBeenCalledWith(
"Kevin",
"create connection"
);
expect(getPartialUserMock).toHaveBeenCalledWith(
uid,
"create connection",
["uid", "name"]
);
expect(createUserMock).toHaveBeenCalledWith(me, myFriend, 100);
});
it("should fail if user and receiver are the same", async () => {
//GIVEN
const me = { uid, name: "Bob" };
getUserByNameMock.mockResolvedValue(me as any);
getPartialUserMock.mockResolvedValue(me as any);
//WHEN
const { body } = await mockApp
.post("/connections")
.send({ receiverName: "Bob" })
.set("Authorization", `Bearer ${uid}`)
.expect(400);
//THEN
expect(body.message).toEqual("You cannot be your own friend, sorry.");
});
it("should fail without mandatory properties", async () => {
//WHEN
const { body } = await mockApp
.post("/connections")
.send({})
.set("Authorization", `Bearer ${uid}`)
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: [`"receiverName" Required`],
});
});
it("should fail with extra properties", async () => {
//WHEN
const { body } = await mockApp
.post("/connections")
.send({ receiverName: "1", extra: "value" })
.set("Authorization", `Bearer ${uid}`)
.expect(422);
//THEN
expect(body).toStrictEqual({
message: "Invalid request data schema",
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
});
});
it("should fail if connections endpoints are disabled", async () => {
await expectFailForDisabledEndpoint(
mockApp
.post("/connections")
.send({ receiverName: "1" })
.set("Authorization", `Bearer ${uid}`)
);
});
it("should fail without authentication", async () => {
await mockApp.post("/connections").expect(401);
});
});
describe("delete connection", () => {
const deleteByIdMock = vi.spyOn(ConnectionsDal, "deleteById");
beforeEach(() => {
deleteByIdMock.mockClear().mockResolvedValue();
});
it("should delete by id", async () => {
//WHEN
await mockApp
.delete("/connections/1")
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(deleteByIdMock).toHaveBeenCalledWith(uid, "1");
});
it("should fail if connections endpoints are disabled", async () => {
await expectFailForDisabledEndpoint(
mockApp.delete("/connections/1").set("Authorization", `Bearer ${uid}`)
);
});
it("should fail without authentication", async () => {
await mockApp.delete("/connections/1").expect(401);
});
});
describe("update connection", () => {
const updateStatusMock = vi.spyOn(ConnectionsDal, "updateStatus");
beforeEach(() => {
updateStatusMock.mockClear().mockResolvedValue();
});
it("should accept", async () => {
//WHEN
await mockApp
.patch("/connections/1")
.send({ status: "accepted" })
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(updateStatusMock).toHaveBeenCalledWith(uid, "1", "accepted");
});
it("should block", async () => {
//WHEN
await mockApp
.patch("/connections/1")
.send({ status: "blocked" })
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(updateStatusMock).toHaveBeenCalledWith(uid, "1", "blocked");
});
it("should fail for invalid status", async () => {
const { body } = await mockApp
.patch("/connections/1")
.send({ status: "invalid" })
.set("Authorization", `Bearer ${uid}`)
.expect(422);
expect(body).toEqual({
message: "Invalid request data schema",
validationErrors: [
`"status" Invalid enum value. Expected 'accepted' | 'blocked', received 'invalid'`,
],
});
});
it("should fail if connections endpoints are disabled", async () => {
await expectFailForDisabledEndpoint(
mockApp
.patch("/connections/1")
.send({ status: "accepted" })
.set("Authorization", `Bearer ${uid}`)
);
});
it("should fail without authentication", async () => {
await mockApp
.patch("/connections/1")
.send({ status: "accepted" })
.expect(401);
});
});
});
async function enableConnectionsEndpoints(enabled: boolean): Promise<void> {
const mockConfig = _.merge(await configuration, {
connections: { enabled },
});
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig
);
}
async function expectFailForDisabledEndpoint(call: SuperTest): Promise<void> {
await enableConnectionsEndpoints(false);
const { body } = await call.expect(503);
expect(body.message).toEqual("Connections are not available at this time.");
}

View file

@ -36,7 +36,9 @@ import { MonkeyMail, UserStreak } from "@monkeytype/schemas/users";
import MonkeyError, { isFirebaseError } from "../../../src/utils/error";
import { LeaderboardEntry } from "@monkeytype/schemas/leaderboards";
import * as WeeklyXpLeaderboard from "../../../src/services/weekly-xp-leaderboard";
import * as ConnectionsDal from "../../../src/dal/connections";
import { pb } from "../../__testData__/users";
import { SuperTest } from "supertest";
const { mockApp, uid, mockAuth } = setup();
const configuration = Configuration.getCachedConfiguration();
@ -224,7 +226,7 @@ describe("user controller test", () => {
userIsNameAvailableMock.mockClear();
});
it("returns ok if name is available", async () => {
it("returns available if name is available", async () => {
//GIVEN
userIsNameAvailableMock.mockResolvedValue(true);
@ -236,13 +238,13 @@ describe("user controller test", () => {
//THEN
expect(body).toEqual({
message: "Username available",
data: null,
message: "Check username",
data: { available: true },
});
expect(userIsNameAvailableMock).toHaveBeenCalledWith("bob", "");
});
it("returns 409 if name is not available", async () => {
it("returns taken if name is not available", async () => {
//GIVEN
userIsNameAvailableMock.mockResolvedValue(false);
@ -250,10 +252,13 @@ describe("user controller test", () => {
const { body } = await mockApp
.get("/users/checkName/bob")
//no authentication required
.expect(409);
.expect(200);
//THEN
expect(body.message).toEqual("Username unavailable");
expect(body).toEqual({
message: "Check username",
data: { available: false },
});
expect(userIsNameAvailableMock).toHaveBeenCalledWith("bob", "");
});
@ -269,11 +274,17 @@ describe("user controller test", () => {
//THEN
expect(body).toEqual({
message: "Username available",
data: null,
message: "Check username",
data: { available: true },
});
expect(userIsNameAvailableMock).toHaveBeenCalledWith("bob", uid);
});
it("returns 422 if username contains profanity", async () => {
await mockApp
.get("/users/checkName/newMiodec")
//no authentication required
.expect(422);
});
});
describe("sendVerificationEmail", () => {
const adminGetUserMock = vi.fn();
@ -624,6 +635,7 @@ describe("user controller test", () => {
"purgeUserFromXpLeaderboards"
);
const blocklistAddMock = vi.spyOn(BlocklistDal, "add");
const connectionsDeletebyUidMock = vi.spyOn(ConnectionsDal, "deleteByUid");
const logsDeleteUserMock = vi.spyOn(LogDal, "deleteUserLogs");
beforeEach(() => {
@ -636,6 +648,7 @@ describe("user controller test", () => {
deleteConfigMock,
purgeUserFromDailyLeaderboardsMock,
purgeUserFromXpLeaderboardsMock,
connectionsDeletebyUidMock,
logsDeleteUserMock,
].forEach((it) => it.mockResolvedValue(undefined));
@ -654,6 +667,7 @@ describe("user controller test", () => {
deleteAllPresetsMock,
purgeUserFromDailyLeaderboardsMock,
purgeUserFromXpLeaderboardsMock,
connectionsDeletebyUidMock,
logsDeleteUserMock,
].forEach((it) => it.mockClear());
});
@ -684,6 +698,7 @@ describe("user controller test", () => {
expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid);
expect(deleteConfigMock).toHaveBeenCalledWith(uid);
expect(deleteAllResultMock).toHaveBeenCalledWith(uid);
expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid);
expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith(
uid,
(await configuration).dailyLeaderboards
@ -719,6 +734,7 @@ describe("user controller test", () => {
expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid);
expect(deleteConfigMock).toHaveBeenCalledWith(uid);
expect(deleteAllResultMock).toHaveBeenCalledWith(uid);
expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid);
expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith(
uid,
(await configuration).dailyLeaderboards
@ -749,6 +765,7 @@ describe("user controller test", () => {
expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid);
expect(deleteConfigMock).toHaveBeenCalledWith(uid);
expect(deleteAllResultMock).toHaveBeenCalledWith(uid);
expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid);
expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith(
uid,
(await configuration).dailyLeaderboards
@ -778,6 +795,7 @@ describe("user controller test", () => {
expect(deleteAllPresetsMock).not.toHaveBeenCalledWith(uid);
expect(deleteConfigMock).not.toHaveBeenCalledWith(uid);
expect(deleteAllResultMock).not.toHaveBeenCalledWith(uid);
expect(connectionsDeletebyUidMock).not.toHaveBeenCalledWith(uid);
expect(purgeUserFromDailyLeaderboardsMock).not.toHaveBeenCalledWith(
uid,
(await configuration).dailyLeaderboards
@ -818,6 +836,7 @@ describe("user controller test", () => {
expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid);
expect(deleteConfigMock).toHaveBeenCalledWith(uid);
expect(deleteAllResultMock).toHaveBeenCalledWith(uid);
expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid);
expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith(
uid,
(await configuration).dailyLeaderboards
@ -858,6 +877,7 @@ describe("user controller test", () => {
expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid);
expect(deleteConfigMock).toHaveBeenCalledWith(uid);
expect(deleteAllResultMock).toHaveBeenCalledWith(uid);
expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid);
expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith(
uid,
(await configuration).dailyLeaderboards
@ -978,6 +998,7 @@ describe("user controller test", () => {
const blocklistContainsMock = vi.spyOn(BlocklistDal, "contains");
const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser");
const updateNameMock = vi.spyOn(UserDal, "updateName");
const connectionsUpdateNameMock = vi.spyOn(ConnectionsDal, "updateName");
const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog");
beforeEach(() => {
@ -985,6 +1006,7 @@ describe("user controller test", () => {
blocklistContainsMock,
getPartialUserMock,
updateNameMock,
connectionsUpdateNameMock,
addImportantLogMock,
].forEach((it) => {
it.mockClear().mockResolvedValue(null as never);
@ -1016,6 +1038,7 @@ describe("user controller test", () => {
"changed name from Bob to newName",
uid
);
expect(connectionsUpdateNameMock).toHaveBeenCalledWith(uid, "newName");
});
it("should fail if username is blocked", async () => {
@ -1032,6 +1055,7 @@ describe("user controller test", () => {
//THEN
expect(body.message).toEqual("Username blocked");
expect(updateNameMock).not.toHaveBeenCalled();
expect(connectionsUpdateNameMock).not.toHaveBeenCalled();
});
it("should fail for banned users", async () => {
@ -3875,6 +3899,62 @@ describe("user controller test", () => {
});
});
});
describe("get friends", () => {
const getFriendsMock = vi.spyOn(UserDal, "getFriends");
beforeEach(() => {
enableConnectionsEndpoints(true);
getFriendsMock.mockClear();
});
it("gets with premium enabled", async () => {
//GIVEN
enablePremiumFeatures(true);
const friend: UserDal.DBFriend = {
name: "Bob",
isPremium: true,
} as any;
getFriendsMock.mockResolvedValue([friend]);
//WHEN
const { body } = await mockApp
.get("/users/friends")
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(body.data).toEqual([{ name: "Bob", isPremium: true }]);
});
it("gets with premium disabled", async () => {
//GIVEN
enablePremiumFeatures(false);
const friend: UserDal.DBFriend = {
name: "Bob",
isPremium: true,
} as any;
getFriendsMock.mockResolvedValue([friend]);
//WHEN
const { body } = await mockApp
.get("/users/friends")
.set("Authorization", `Bearer ${uid}`)
.expect(200);
//THEN
expect(body.data).toEqual([{ name: "Bob" }]);
});
it("should fail if friends endpoints are disabled", async () => {
await expectFailForDisabledEndpoint(
mockApp.get("/users/friends").set("Authorization", `Bearer ${uid}`)
);
});
it("should fail without authentication", async () => {
await mockApp.get("/users/friends").expect(401);
});
});
});
function fillYearWithDay(days: number): number[] {
@ -3974,3 +4054,18 @@ async function enableReporting(enabled: boolean): Promise<void> {
mockConfig
);
}
async function enableConnectionsEndpoints(enabled: boolean): Promise<void> {
const mockConfig = _.merge(await configuration, {
connections: { enabled },
});
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig
);
}
async function expectFailForDisabledEndpoint(call: SuperTest): Promise<void> {
await enableConnectionsEndpoints(false);
const { body } = await call.expect(503);
expect(body.message).toEqual("Connections are not available at this time.");
}

View file

@ -99,6 +99,12 @@ export function getOpenApi(): OpenAPIObject {
description: "All-time and daily leaderboards of the fastest typers.",
"x-displayName": "Leaderboards",
},
{
name: "connections",
description: "Connections between users.",
"x-displayName": "Connections",
"x-public": "no",
},
{
name: "psas",
description: "Public service announcements.",

View file

@ -0,0 +1,85 @@
import {
CreateConnectionRequest,
CreateConnectionResponse,
GetConnectionsQuery,
GetConnectionsResponse,
IdPathParams,
UpdateConnectionRequest,
} from "@monkeytype/contracts/connections";
import { MonkeyRequest } from "../types";
import { MonkeyResponse } from "../../utils/monkey-response";
import * as ConnectionsDal from "../../dal/connections";
import * as UserDal from "../../dal/user";
import { replaceObjectId } from "../../utils/misc";
import MonkeyError from "../../utils/error";
import { omit } from "lodash";
import { Connection } from "@monkeytype/schemas/connections";
function convert(db: ConnectionsDal.DBConnection): Connection {
return replaceObjectId(omit(db, "key"));
}
export async function getConnections(
req: MonkeyRequest<GetConnectionsQuery>
): Promise<GetConnectionsResponse> {
const { uid } = req.ctx.decodedToken;
const { status, type } = req.query;
const results = await ConnectionsDal.getConnections({
initiatorUid:
type === undefined || type.includes("outgoing") ? uid : undefined,
receiverUid:
type === undefined || type?.includes("incoming") ? uid : undefined,
status: status,
});
return new MonkeyResponse("Connections retrieved", results.map(convert));
}
export async function createConnection(
req: MonkeyRequest<undefined, CreateConnectionRequest>
): Promise<CreateConnectionResponse> {
const { uid } = req.ctx.decodedToken;
const { receiverName } = req.body;
const { maxPerUser } = req.ctx.configuration.connections;
const receiver = await UserDal.getUserByName(
receiverName,
"create connection"
);
if (uid === receiver.uid) {
throw new MonkeyError(400, "You cannot be your own friend, sorry.");
}
const initiator = await UserDal.getPartialUser(uid, "create connection", [
"uid",
"name",
]);
const result = await ConnectionsDal.create(initiator, receiver, maxPerUser);
return new MonkeyResponse("Connection created", convert(result));
}
export async function deleteConnection(
req: MonkeyRequest<undefined, undefined, IdPathParams>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { id } = req.params;
await ConnectionsDal.deleteById(uid, id);
return new MonkeyResponse("Connection deleted", null);
}
export async function updateConnection(
req: MonkeyRequest<undefined, UpdateConnectionRequest, IdPathParams>
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const { id } = req.params;
const { status } = req.body;
await ConnectionsDal.updateStatus(uid, id, status);
return new MonkeyResponse("Connection updated", null);
}

View file

@ -51,6 +51,7 @@ import {
AddTagRequest,
AddTagResponse,
CheckNamePathParameters,
CheckNameResponse,
CreateUserRequest,
DeleteCustomThemeRequest,
EditCustomThemeRequst,
@ -60,6 +61,7 @@ import {
GetCustomThemesResponse,
GetDiscordOauthLinkResponse,
GetFavoriteQuotesResponse,
GetFriendsResponse,
GetPersonalBestsQuery,
GetPersonalBestsResponse,
GetProfilePathParams,
@ -89,6 +91,7 @@ import {
import { MILLISECONDS_IN_DAY } from "@monkeytype/util/date-and-time";
import { MonkeyRequest } from "../types";
import { tryCatch } from "@monkeytype/util/trycatch";
import * as ConnectionsDal from "../../dal/connections";
async function verifyCaptcha(captcha: string): Promise<void> {
const { data: verified, error } = await tryCatch(verify(captcha));
@ -292,6 +295,7 @@ export async function deleteUser(req: MonkeyRequest): Promise<MonkeyResponse> {
uid,
req.ctx.configuration.leaderboards.weeklyXp
),
ConnectionsDal.deleteByUid(uid),
]);
try {
@ -382,6 +386,8 @@ export async function updateName(
}
await UserDAL.updateName(uid, name, user.name);
await ConnectionsDal.updateName(uid, name);
void addImportantLog(
"user_name_updated",
`changed name from ${user.name} to ${name}`,
@ -425,16 +431,15 @@ export async function optOutOfLeaderboards(
export async function checkName(
req: MonkeyRequest<undefined, undefined, CheckNamePathParameters>
): Promise<MonkeyResponse> {
): Promise<CheckNameResponse> {
const { name } = req.params;
const { uid } = req.ctx.decodedToken;
const available = await UserDAL.isNameAvailable(name, uid);
if (!available) {
throw new MonkeyError(409, "Username unavailable");
}
return new MonkeyResponse("Username available", null);
return new MonkeyResponse("Check username", {
available,
});
}
export async function updateEmail(
@ -1261,3 +1266,19 @@ export async function getStreak(
return new MonkeyResponse("Streak data retrieved", user.streak ?? null);
}
export async function getFriends(
req: MonkeyRequest
): Promise<GetFriendsResponse> {
const { uid } = req.ctx.decodedToken;
const premiumEnabled = req.ctx.configuration.users.premium.enabled;
const data = await UserDAL.getFriends(uid);
if (!premiumEnabled) {
for (const friend of data) {
delete friend.isPremium;
}
}
return new MonkeyResponse("Friends retrieved", data);
}

View file

@ -0,0 +1,25 @@
import { connectionsContract } from "@monkeytype/contracts/connections";
import { initServer } from "@ts-rest/express";
import { callController } from "../ts-rest-adapter";
import * as ConnectionsController from "../controllers/connections";
const s = initServer();
export default s.router(connectionsContract, {
get: {
handler: async (r) =>
callController(ConnectionsController.getConnections)(r),
},
create: {
handler: async (r) =>
callController(ConnectionsController.createConnection)(r),
},
delete: {
handler: async (r) =>
callController(ConnectionsController.deleteConnection)(r),
},
update: {
handler: async (r) =>
callController(ConnectionsController.updateConnection)(r),
},
});

View file

@ -16,6 +16,7 @@ import configs from "./configs";
import configuration from "./configuration";
import { version } from "../../version";
import leaderboards from "./leaderboards";
import connections from "./connections";
import addSwaggerMiddlewares from "./swagger";
import { MonkeyResponse } from "../../utils/monkey-response";
import {
@ -61,6 +62,7 @@ const router = s.router(contract, {
users,
quotes,
webhooks,
connections,
});
export function addApiRoutes(app: Application): void {

View file

@ -137,4 +137,7 @@ export default s.router(usersContract, {
getStreak: {
handler: async (r) => callController(UserController.getStreak)(r),
},
getFriends: {
handler: async (r) => callController(UserController.getFriends)(r),
},
});

View file

@ -103,6 +103,7 @@ export const BASE_CONFIGURATION: Configuration = {
xpRewardBrackets: [],
},
},
connections: { enabled: false, maxPerUser: 100 },
};
type BaseSchema = {
@ -603,5 +604,16 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema<Configuration> = {
},
},
},
connections: {
type: "object",
label: "Connections",
fields: {
enabled: { type: "boolean", label: "Enabled" },
maxPerUser: {
type: "number",
label: "Max Connections per user",
},
},
},
},
};

View file

@ -0,0 +1,202 @@
import { Collection, Filter, ObjectId } from "mongodb";
import * as db from "../init/db";
import { Connection, ConnectionStatus } from "@monkeytype/schemas/connections";
import MonkeyError from "../utils/error";
import { WithObjectId } from "../utils/misc";
export type DBConnection = WithObjectId<
Connection & {
key: string; //sorted uid
}
>;
export const getCollection = (): Collection<DBConnection> =>
db.collection("connections");
export async function getConnections(options: {
initiatorUid?: string;
receiverUid?: string;
status?: ConnectionStatus[];
}): Promise<DBConnection[]> {
const { initiatorUid, receiverUid, status } = options;
if (initiatorUid === undefined && receiverUid === undefined)
throw new Error("Missing filter");
let filter: Filter<DBConnection> = { $or: [] };
if (initiatorUid !== undefined) {
filter.$or?.push({ initiatorUid });
}
if (receiverUid !== undefined) {
filter.$or?.push({ receiverUid });
}
if (status !== undefined) {
filter.status = { $in: status };
}
return await getCollection().find(filter).toArray();
}
export async function create(
initiator: { uid: string; name: string },
receiver: { uid: string; name: string },
maxPerUser: number
): Promise<DBConnection> {
const count = await getCollection().countDocuments({
initiatorUid: initiator.uid,
});
if (count >= maxPerUser) {
throw new MonkeyError(
409,
"Maximum number of connections reached",
"create connection request"
);
}
const key = getKey(initiator.uid, receiver.uid);
try {
const created: DBConnection = {
_id: new ObjectId(),
key,
initiatorUid: initiator.uid,
initiatorName: initiator.name,
receiverUid: receiver.uid,
receiverName: receiver.name,
lastModified: Date.now(),
status: "pending",
};
await getCollection().insertOne(created);
return created;
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (e.name === "MongoServerError" && e.code === 11000) {
const existing = await getCollection().findOne(
{ key },
{ projection: { status: 1 } }
);
let message = "";
if (existing?.status === "accepted") {
message = "Connection already exists";
} else if (existing?.status === "pending") {
message = "Connection request already sent";
} else if (existing?.status === "blocked") {
if (existing.initiatorUid === initiator.uid) {
message = "Connection blocked by initiator";
} else {
message = "Connection blocked by receiver";
}
} else {
message = "Duplicate connection";
}
throw new MonkeyError(409, message);
}
throw e;
}
}
/**
*Update the status of a connection by id
* @param receiverUid
* @param id
* @param status
* @throws MonkeyError if the connection id is unknown or the recieverUid does not match
*/
export async function updateStatus(
receiverUid: string,
id: string,
status: ConnectionStatus
): Promise<void> {
const updateResult = await getCollection().updateOne(
{
_id: new ObjectId(id),
receiverUid,
},
{ $set: { status, lastModified: Date.now() } }
);
if (updateResult.matchedCount === 0) {
throw new MonkeyError(404, "No permission or connection not found");
}
}
/**
* delete a connection by the id.
* @param uid
* @param id
* @throws MonkeyError if the connection id is unknown or uid does not match
*/
export async function deleteById(uid: string, id: string): Promise<void> {
const deletionResult = await getCollection().deleteOne({
$and: [
{
_id: new ObjectId(id),
},
{
$or: [
{ receiverUid: uid },
{ status: { $in: ["accepted", "pending"] }, initiatorUid: uid },
],
},
],
});
if (deletionResult.deletedCount === 0) {
throw new MonkeyError(404, "No permission or connection not found");
}
}
/**
* Update all connections for the uid (initiator or receiver) with the given name.
* @param uid
* @param newName
*/
export async function updateName(uid: string, newName: string): Promise<void> {
await getCollection().bulkWrite([
{
updateMany: {
filter: { initiatorUid: uid },
update: { $set: { initiatorName: newName } },
},
},
{
updateMany: {
filter: { receiverUid: uid },
update: { $set: { receiverName: newName } },
},
},
]);
}
/**
* Remove all connections containing the uid as initiatorUid or receiverUid
* @param uid
*/
export async function deleteByUid(uid: string): Promise<void> {
await getCollection().deleteMany({
$or: [{ initiatorUid: uid }, { receiverUid: uid }],
});
}
function getKey(initiatorUid: string, receiverUid: string): string {
const ids = [initiatorUid, receiverUid];
ids.sort();
return ids.join("/");
}
export async function createIndicies(): Promise<void> {
//index used for search
await getCollection().createIndex({ initiatorUid: 1, status: 1 });
await getCollection().createIndex({ receiverUid: 1, status: 1 });
//make sure there is only one connection for each initiatorr/receiver
await getCollection().createIndex({ key: 1 }, { unique: true });
}

View file

@ -26,6 +26,7 @@ import {
UserTag,
User,
CountByYearAndDay,
Friend,
} from "@monkeytype/schemas/users";
import { Mode, Mode2, PersonalBest } from "@monkeytype/schemas/shared";
import { addImportantLog } from "./logs";
@ -33,6 +34,7 @@ import { Result as ResultType } from "@monkeytype/schemas/results";
import { Configuration } from "@monkeytype/schemas/configuration";
import { isToday, isYesterday } from "@monkeytype/util/date-and-time";
import GeorgeQueue from "../queues/george-queue";
import { getCollection as getConnectionCollection } from "./connections";
export type DBUserTag = WithObjectId<UserTag>;
@ -65,6 +67,8 @@ const SECONDS_PER_HOUR = 3600;
type Result = Omit<ResultType<Mode>, "_id" | "name">;
export type DBFriend = Friend;
// Export for use in tests
export const getUsersCollection = (): Collection<DBUser> =>
db.collection<DBUser>("users");
@ -1222,3 +1226,212 @@ async function updateUser(
error.stack
);
}
export async function getFriends(uid: string): Promise<DBFriend[]> {
return (await getConnectionCollection()
.aggregate([
{
$match: {
//uid is friend or initiator
$and: [
{
$or: [{ initiatorUid: uid }, { receiverUid: uid }],
status: "accepted",
},
],
},
},
{
$project: {
receiverUid: true,
initiatorUid: true,
lastModified: true,
},
},
{
$addFields: {
//pick the other user, not uid
uid: {
$cond: {
if: { $eq: ["$receiverUid", uid] },
// oxlint-disable-next-line no-thenable
then: "$initiatorUid",
else: "$receiverUid",
},
},
},
},
// we want to fetch the data for our uid as well, add it to the list of documents
// workaround for missing unionWith + $documents in mongodb 5.0
{
$group: {
_id: null,
data: {
$push: {
uid: "$uid",
lastModified: "$lastModified",
connectionId: "$_id",
},
},
},
},
{
$project: {
data: {
$concatArrays: ["$data", [{ uid }]],
},
},
},
{
$unwind: "$data",
},
/* end of workaround, this is the replacement for >= 5.1
{ $addFields: { connectionId: "$_id" } },
{ $project: { uid: true, lastModified: true, connectionId: true } },
{
$unionWith: {
pipeline: [{ $documents: [{ uid }] }],
},
},
*/
{
$lookup: {
/* query users to get the friend data */
from: "users",
localField: "data.uid", //just uid if we remove the workaround above
foreignField: "uid",
as: "result",
let: {
lastModified: "$data.lastModified", //just $lastModified if we remove the workaround above
connectionId: "$data.connectionId", //just $connectionId if we remove the workaround above
},
pipeline: [
{
$project: {
_id: false,
uid: true,
connectionId: true,
name: true,
discordId: true,
discordAvatar: true,
startedTests: true,
completedTests: true,
timeTyping: true,
xp: true,
"streak.length": true,
"streak.maxLength": true,
personalBests: true,
"inventory.badges": true,
"premium.expirationTimestamp": true,
banned: 1,
lbOptOut: 1,
},
},
{
$addFields: {
lastModified: "$$lastModified",
connectionId: "$$connectionId",
top15: {
$reduce: {
//find highest wpm from time 15 PBs
input: "$personalBests.time.15",
initialValue: {},
in: {
$cond: [
{ $gte: ["$$this.wpm", "$$value.wpm"] },
"$$this",
"$$value",
],
},
},
},
top60: {
$reduce: {
//find highest wpm from time 60 PBs
input: "$personalBests.time.60",
initialValue: {},
in: {
$cond: [
{ $gte: ["$$this.wpm", "$$value.wpm"] },
"$$this",
"$$value",
],
},
},
},
badgeId: {
$ifNull: [
{
$first: {
$map: {
input: {
$filter: {
input: "$inventory.badges",
as: "badge",
cond: { $eq: ["$$badge.selected", true] },
},
},
as: "selectedBadge",
in: "$$selectedBadge.id",
},
},
},
"$$REMOVE",
],
},
isPremium: {
$cond: {
if: {
$or: [
{ $eq: ["$premium.expirationTimestamp", -1] },
{
$gt: [
"$premium.expirationTimestamp",
{ $toLong: "$$NOW" },
],
},
],
},
// oxlint-disable-next-line no-thenable
then: true,
else: "$$REMOVE",
},
},
},
},
{
$addFields: {
//remove nulls
top15: { $ifNull: ["$top15", "$$REMOVE"] },
top60: { $ifNull: ["$top60", "$$REMOVE"] },
badgeId: { $ifNull: ["$badgeId", "$$REMOVE"] },
lastModified: "$lastModified",
},
},
{
$project: {
personalBests: false,
inventory: false,
premium: false,
},
},
],
},
},
{
$replaceRoot: {
newRoot: {
$cond: [
{ $gt: [{ $size: "$result" }, 0] },
{ $first: "$result" },
{}, // empty document fallback, this can happen if the user is not present
],
},
},
},
])
.toArray()) as DBFriend[];
}

View file

@ -17,6 +17,7 @@ import * as EmailClient from "./init/email-client";
import { init as initFirebaseAdmin } from "./init/firebase-admin";
import { createIndicies as leaderboardDbSetup } from "./dal/leaderboards";
import { createIndicies as blocklistDbSetup } from "./dal/blocklist";
import { createIndicies as connectionsDbSetup } from "./dal/connections";
import { getErrorMessage } from "./utils/error";
async function bootServer(port: number): Promise<Server> {
@ -76,6 +77,9 @@ async function bootServer(port: number): Promise<Server> {
Logger.info("Setting up blocklist indicies...");
await blocklistDbSetup();
Logger.info("Setting up connections indicies...");
await connectionsDbSetup();
recordServerVersion(version);
} catch (error) {
Logger.error("Failed to boot server");

View file

@ -116,7 +116,7 @@
<div class="icon">
<i class="fas fa-fw fa-bell"></i>
</div>
<div class="notificationBubble hidden">5</div>
<div class="notificationBubble hidden"></div>
</button>
<a
class="textButton view-login"
@ -137,6 +137,7 @@
<i class="fas fa-fw fa-spin fa-circle-notch"></i>
</div>
<div class="avatar"></div>
<div class="notificationBubble hidden"></div>
<div class="text"></div>
<div class="levelAndBar">
<div class="level" data-balloon-pos="up">1</div>
@ -157,6 +158,16 @@
<i class="fas fa-fw fa-chart-line"></i>
User stats
</a>
<a
href="/friends"
class="button goToFriends"
onclick="this.blur();"
router-link
>
<i class="fas fa-fw fa-user-friends"></i>
Friends
<span class="notificationBubble hidden">99</span>
</a>
<a
href="/404"
class="button goToProfile"

View file

@ -14,6 +14,10 @@
<i class="fas fa-key"></i>
authentication
</button>
<button class="text" data-tab="blockedUsers">
<i class="fas fa-user-shield"></i>
blocked users
</button>
<button class="text" data-tab="apeKeys">
<i class="fas fa-code"></i>
ape keys
@ -241,6 +245,27 @@
</div>
</div>
</div>
<div class="tab hidden" data-tab="blockedUsers">
<div class="section blockedUsers">
<div class="title">
<i class="fas fa-user-shield"></i>
<span>blocked users</span>
</div>
<div class="text">
Blocked users cannot send you friend requests.
</div>
</div>
<table>
<thead>
<tr>
<td>name</td>
<td>blocked on</td>
<td></td>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="tab hidden" data-tab="dangerZone">
<div class="section resetAccount">
<div class="title">

View file

@ -0,0 +1,82 @@
<div class="page pageFriends hidden" id="pageFriends">
<div class="preloader hidden">
<div class="icon">
<i class="fas fa-fw fa-spin fa-circle-notch"></i>
</div>
<div class="barWrapper hidden">
<div class="bar">
<div class="fill"></div>
</div>
<div class="text"></div>
</div>
</div>
<div class="content">
<div class="pendingRequests">
<div class="bigTitle">
<i class="fas fa-user-plus fa-fw"></i>
Incoming Requests
</div>
<div class="error hidden">
<i class="fas fa-times"></i>
<p>Something went wrong</p>
</div>
<table width="100%">
<thead>
<tr>
<td>user</td>
<td>date</td>
<td></td>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="friends">
<div class="titleAndButton">
<div class="bigTitle">
<i class="fas fa-user-friends fa-fw"></i>
Friends
<i class="spinner hidden fas fa-circle-notch fa-spin"></i>
</div>
<button class="button friendAdd">
<i class="fas fa-plus fa-fw"></i>
add friend
</button>
</div>
<div class="loading hidden">
<i class="fas fa-circle-notch fa-spin"></i>
</div>
<div class="error hidden">
<i class="fas fa-times"></i>
<p>Something went wrong</p>
</div>
<div class="nodata hidden">You don't have any friends :(</div>
<table width="100%">
<thead>
<tr>
<td data-sort-property="name">name</td>
<td data-sort-property="addedAt">friends for</td>
<td data-sort-property="xp">level</td>
<td
data-sort-property="completedTests"
aria-label="completed / started"
data-balloon-pos="up"
>
tests
</td>
<td data-sort-property="timeTyping">time typing</td>
<td data-sort-property="streak.length">streak</td>
<td data-sort-property="top15.wpm">time 15 pb</td>
<td data-sort-property="top60.wpm">time 60 pb</td>
<td width="1px"></td>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>

View file

@ -50,6 +50,7 @@
<load src="html/pages/test.html" />
<load src="html/pages/404.html" />
<load src="html/pages/account-settings.html" />
<load src="html/pages/friends.html" />
<load src="html/pages/leaderboards.html" />
</main>
<load src="html/footer.html" />

View file

@ -116,16 +116,13 @@
}
}
}
&[data-tab="apeKeys"] {
&[data-tab="apeKeys"],
&[data-tab="blockedUsers"] {
table {
width: 100%;
border-spacing: 0;
border-collapse: collapse;
tr td:first-child {
text-align: center;
}
tr.me {
td {
color: var(--main-color);
@ -185,6 +182,21 @@
}
}
}
&[data-tab="apeKeys"] {
tr td:first-child {
text-align: center;
}
}
&[data-tab="blockedUsers"] {
tr td:first-child a {
text-decoration: none;
color: var(--text-color);
}
tr td:last-child {
text-align: right;
}
}
}
}
// .right {

View file

@ -406,10 +406,10 @@ key {
line-height: 2em;
color: transparent;
border-radius: 100rem;
right: 0.5em;
top: 0.5em;
box-shadow: 0 0 0 0.5em var(--bg-color);
transition: 0.125s;
right: 0.5em;
top: 0.5em;
}
#fpsCounter {
@ -448,3 +448,17 @@ key {
pointer-events: none !important;
}
}
//sorted-tables
.headerSorted {
font-weight: bold;
}
table {
z-index: 0;
td.sortable:hover {
cursor: pointer;
-webkit-user-select: none;
user-select: none;
background-color: var(--sub-alt-color);
}
}

View file

@ -0,0 +1,143 @@
.pageFriends {
.bigTitle {
color: var(--sub-color);
font-size: 2rem;
}
.friendAdd {
padding-left: 1em;
padding-right: 1em;
}
.titleAndButton {
display: grid;
grid-template-columns: 1fr auto;
margin-bottom: 1rem;
align-items: center;
}
.nodata {
color: var(--sub-color);
padding: 5rem 0;
text-align: center;
}
.pendingRequests,
.friends {
margin-bottom: 4rem;
table {
border-spacing: 0;
border-collapse: collapse;
color: var(--text-color);
--padding: 1em 1.5rem;
.small {
font-size: 0.75em;
}
thead {
color: var(--sub-color);
font-size: 0.75rem;
}
tr.me {
color: var(--main-color);
}
tbody tr:nth-child(odd) td {
background: var(--sub-alt-color);
}
tbody td:first-child {
border-radius: var(--roundness) 0 0 var(--roundness);
}
tbody td:last-child {
border-radius: 0 var(--roundness) var(--roundness) 0;
}
td {
padding: var(--padding);
appearance: unset;
&:last-child {
text-align: right;
}
//don't wrap friendsfor rand streak into multiple lines
&:nth-child(2),
&:nth-child(6) {
white-space: nowrap;
}
}
.sub {
opacity: 0.5;
}
// .actions button {
// opacity: 0;
// transition: opacity 0.125s;
// }
// tr:hover button {
// opacity: 1;
// }
}
}
.pendingRequests {
table tr {
td:first-child a {
text-decoration: none;
color: var(--text-color);
}
}
}
.friends {
.avatarNameBadge {
display: grid;
grid-template-columns: 1.25em max-content auto;
gap: 0.5em;
place-items: center left;
.avatarPlaceholder {
width: 1.25em;
height: 1.25em;
font-size: 1.25em;
// background: var(--sub-color);
color: var(--sub-color);
// display: grid;
// place-content: center center;
border-radius: 100%;
}
.entryName {
text-decoration: none;
color: inherit;
cursor: pointer;
}
.avatarPlaceholder,
.avatar {
grid-row: 1/2;
grid-column: 1/2;
.userIcon {
color: var(--sub-color);
}
}
.badge {
font-size: 0.6em;
}
.flagsAndBadge {
display: flex;
gap: 0.5em;
color: var(--sub-color);
place-items: center;
}
}
}
.loading {
display: grid;
place-items: center;
font-size: 3em;
color: var(--sub-color);
padding: 1em;
}
}

View file

@ -1,5 +1,5 @@
@import "buttons", "fonts", "404", "ads", "about", "account", "animations",
"banners", "caret", "commandline", "core", "footer", "inputs", "keymap",
"login", "monkey", "nav", "notifications", "popups", "profile", "scroll",
"settings", "account-settings", "leaderboards", "test", "loading",
"settings", "account-settings", "leaderboards", "test", "loading", "friends",
"media-queries";

View file

@ -100,7 +100,7 @@
.tabs {
padding: 0;
display: grid;
grid-auto-flow: column;
grid-auto-flow: row;
button {
justify-content: center;
padding: 1em 0.5em;
@ -108,6 +108,7 @@
}
}
}
.pageSettings {
.accountSettingsNotice {
button {
@ -235,8 +236,13 @@
// border-radius: 0.15em;
// }
}
}
.pageFriends {
.content .friends table {
font-size: 0.75rem;
}
}
}
@media (pointer: coarse) and (max-width: 778px) {
#restartTestButton {
display: block !important;

View file

@ -101,4 +101,20 @@
}
}
}
.pageFriends {
.content .friends table {
td:nth-child(3) {
display: none;
}
}
}
.pageAccountSettings [data-tab="blockedUsers"] {
table {
font-size: 0.75rem;
td:nth-child(2) {
display: none;
}
}
}
}

View file

@ -283,4 +283,13 @@
}
}
}
.pageFriends {
.content .friends table {
td:nth-child(4),
td:nth-child(7),
td:nth-child(8) {
display: none;
}
}
}
}

View file

@ -15,13 +15,6 @@
.ad.ad-h-s {
display: grid;
}
.pageAccountSettings {
.main {
.tabs {
grid-auto-flow: row;
}
}
}
.pageLeaderboards {
.content .bigtitle .text:after {
@ -322,4 +315,19 @@
.modalWrapper .modal .inputs.withLabel {
grid-template-columns: 1fr;
}
.pageFriends {
.content .friends table {
td:nth-child(5),
td:nth-child(6) {
display: none;
}
}
}
.pageAccountSettings [data-tab="blockedUsers"] {
table {
font-size: 0.75rem;
}
}
}

View file

@ -90,4 +90,13 @@
}
}
}
.pageFriends {
.content .friends table {
font-size: 0.9rem;
.badge .text {
display: none;
}
}
}
}

View file

@ -46,6 +46,12 @@ nav {
gap: 0.33em;
display: grid;
grid-auto-flow: column;
.notificationBubble {
left: 3em;
top: 0.5em;
}
.spinner,
.avatar {
grid-column: 1/2;
@ -191,7 +197,8 @@ nav {
border-radius: var(--roundness);
gap: 0.25em;
* {
a,
button {
justify-content: left;
padding-left: 0;
border-radius: 0;
@ -210,6 +217,18 @@ nav {
border-radius: var(--roundness);
}
}
& .goToFriends {
display: grid;
grid-template-columns: auto 1fr auto;
text-align: left;
.notificationBubble {
margin-right: 0.5em;
position: unset;
box-shadow: 0 0 0 0.5em var(--sub-alt-color);
display: block;
}
}
}
}
&:hover,

View file

@ -4,9 +4,13 @@ import { promiseWithResolvers } from "../utils/misc";
let config: Configuration | undefined = undefined;
const { promise: configPromise, resolve } = promiseWithResolvers<boolean>();
const {
promise: configurationPromise,
resolve,
reject,
} = promiseWithResolvers<boolean>();
export { configPromise };
export { configurationPromise };
export function get(): Configuration | undefined {
return config;
@ -16,7 +20,9 @@ export async function sync(): Promise<void> {
const response = await Ape.configuration.get();
if (response.status !== 200) {
console.error("Could not fetch configuration", response.body.message);
const message = `Could not fetch configuration: ${response.body.message}`;
console.error(message);
reject(message);
return;
} else {
config = response.body.data ?? undefined;

View file

@ -14,6 +14,7 @@ import {
} from "../elements/test-activity-calendar";
import { Preset } from "@monkeytype/schemas/presets";
import { Language } from "@monkeytype/schemas/languages";
import { ConnectionStatus } from "@monkeytype/schemas/connections";
export type SnapshotUserTag = UserTag & {
active?: boolean;
@ -84,6 +85,7 @@ export type Snapshot = Omit<
xp: number;
testActivity?: ModifiableTestActivityCalendar;
testActivityByYear?: { [key: string]: TestActivityCalendar };
connections: Record<string, ConnectionStatus | "incoming">;
};
export type SnapshotPreset = Preset & {
@ -131,6 +133,7 @@ const defaultSnap = {
60: { english: { count: 0, rank: 0 } },
},
},
connections: {},
} as Snapshot;
export function getDefaultSnapshot(): Snapshot {

View file

@ -9,6 +9,7 @@ import * as PageLogin from "../pages/login";
import * as PageLoading from "../pages/loading";
import * as PageProfile from "../pages/profile";
import * as PageProfileSearch from "../pages/profile-search";
import * as Friends from "../pages/friends";
import * as Page404 from "../pages/404";
import * as PageLeaderboards from "../pages/leaderboards";
import * as PageAccountSettings from "../pages/account-settings";
@ -114,6 +115,9 @@ async function showSyncLoading({
PageLoading.page.element.addClass("hidden");
}
// Global abort controller for keyframe promises
let keyframeAbortController: AbortController | null = null;
async function getLoadingPromiseWithBarKeyframes(
loadingOptions: Extract<
NonNullable<Page<unknown>["loadingOptions"]>,
@ -122,13 +126,16 @@ async function getLoadingPromiseWithBarKeyframes(
fillDivider: number,
fillOffset: number
): Promise<void> {
let aborted = false;
let loadingPromise = loadingOptions.loadingPromise();
// Animate bar keyframes, but allow aborting if loading.promise finishes first
// Create abort controller for this keyframe sequence
const localAbortController = new AbortController();
keyframeAbortController = localAbortController;
// Animate bar keyframes, but allow aborting if loading.promise finishes first or if globally aborted
const keyframePromise = (async () => {
for (const keyframe of loadingOptions.keyframes) {
if (aborted) break;
if (localAbortController.signal.aborted) break;
if (keyframe.text !== undefined) {
PageLoading.updateText(keyframe.text);
}
@ -144,12 +151,18 @@ async function getLoadingPromiseWithBarKeyframes(
keyframePromise,
(async () => {
await loadingPromise;
aborted = true;
localAbortController.abort();
})(),
]);
// Always wait for loading.promise to finish before continuing
await loadingPromise;
// Clean up the abort controller
if (keyframeAbortController === localAbortController) {
keyframeAbortController = null;
}
return;
}
@ -186,6 +199,7 @@ export async function change(
login: PageLogin.page,
profile: PageProfile.page,
profileSearch: PageProfileSearch.page,
friends: Friends.page,
404: Page404.page,
accountSettings: PageAccountSettings.page,
leaderboards: PageLeaderboards.page,
@ -234,7 +248,18 @@ export async function change(
easingMethod,
});
}
// Clean up abort controller after successful loading
if (keyframeAbortController) {
keyframeAbortController = null;
}
} catch (error) {
// Abort any running keyframe promises
if (keyframeAbortController) {
keyframeAbortController.abort();
keyframeAbortController = null;
}
pages.loading.element.addClass("active");
ActivePage.set(pages.loading.id);
Focus.set(false);

View file

@ -144,6 +144,21 @@ const routes: Route[] = [
});
},
},
{
path: "/friends",
load: async (_params, options) => {
if (!isAuthAvailable()) {
await navigate("/", options);
return;
}
if (!isAuthenticated()) {
await navigate("/login", options);
return;
}
await PageController.change("friends", options);
},
},
];
export async function navigate(

View file

@ -19,12 +19,19 @@ const flags: UserFlag[] = [
color: "var(--error-color)",
test: (it) => it.lbOptOut === true,
},
{
name: "Friend",
description: "Friend :)",
icon: "fa-user-friends",
test: (it) => it.isFriend === true,
},
];
export type SupportsFlags = {
isPremium?: boolean;
banned?: boolean;
lbOptOut?: boolean;
isFriend?: boolean;
};
type UserFlag = {
@ -39,6 +46,7 @@ type UserFlag = {
type UserFlagOptions = {
iconsOnly?: boolean;
isFriend?: boolean;
};
const USER_FLAG_OPTIONS_DEFAULT: UserFlagOptions = {

View file

@ -1,6 +1,6 @@
import Ape from "./ape";
import * as Notifications from "./elements/notifications";
import { isAuthenticated } from "./firebase";
import { isAuthenticated, getAuthenticatedUser } from "./firebase";
import * as ConnectionState from "./states/connection";
import { lastElementFromArray } from "./utils/arrays";
import { migrateConfig } from "./utils/config";
@ -31,6 +31,11 @@ import { FunboxMetadata } from "../../../packages/funbox/src/types";
import { getFirstDayOfTheWeek } from "./utils/date-and-time";
import { Language } from "@monkeytype/schemas/languages";
import * as AuthEvent from "./observables/auth-event";
import {
configurationPromise,
get as getServerConfiguration,
} from "./ape/server-configuration";
import { Connection } from "@monkeytype/schemas/connections";
let dbSnapshot: Snapshot | undefined;
const firstDayOfTheWeek = getFirstDayOfTheWeek();
@ -82,14 +87,22 @@ export function setSnapshot(
export async function initSnapshot(): Promise<Snapshot | false> {
//send api request with token that returns tags, presets, and data needed for snap
const snap = getDefaultSnapshot();
await configurationPromise;
try {
if (!isAuthenticated()) return false;
const [userResponse, configResponse, presetsResponse] = await Promise.all([
Ape.users.get(),
Ape.configs.get(),
Ape.presets.get(),
]);
const connectionsRequest = getServerConfiguration()?.connections.enabled
? Ape.connections.get()
: { status: 200, body: { message: "", data: [] } };
const [userResponse, configResponse, presetsResponse, connectionsResponse] =
await Promise.all([
Ape.users.get(),
Ape.configs.get(),
Ape.presets.get(),
connectionsRequest,
]);
if (userResponse.status !== 200) {
throw new SnapshotInitError(
@ -109,10 +122,17 @@ export async function initSnapshot(): Promise<Snapshot | false> {
presetsResponse.status
);
}
if (connectionsResponse.status !== 200) {
throw new SnapshotInitError(
`${connectionsResponse.body.message} (connections)`,
connectionsResponse.status
);
}
const userData = userResponse.body.data;
const configData = configResponse.body.data;
const presetsData = presetsResponse.body.data;
const connectionsData = connectionsResponse.body.data;
if (userData === null) {
throw new SnapshotInitError(
@ -249,6 +269,8 @@ export async function initSnapshot(): Promise<Snapshot | false> {
);
}
snap.connections = convertConnections(connectionsData);
dbSnapshot = snap;
return dbSnapshot;
} catch (e) {
@ -1083,6 +1105,48 @@ export async function getTestActivityCalendar(
return dbSnapshot.testActivityByYear[yearString];
}
export function mergeConnections(connections: Connection[]): void {
const snapshot = getSnapshot();
if (!snapshot) return;
const update = convertConnections(connections);
for (const [key, value] of Object.entries(update)) {
snapshot.connections[key] = value;
}
setSnapshot(snapshot);
}
function convertConnections(
connectionsData: Connection[]
): Snapshot["connections"] {
return Object.fromEntries(
connectionsData.map((connection) => {
const isMyRequest =
getAuthenticatedUser()?.uid === connection.initiatorUid;
return [
isMyRequest ? connection.receiverUid : connection.initiatorUid,
connection.status === "pending" && !isMyRequest
? "incoming"
: connection.status,
];
})
);
}
export function isFriend(uid: string | undefined): boolean {
if (uid === undefined || uid === getAuthenticatedUser()?.uid) return false;
const snapshot = getSnapshot();
if (!snapshot) return false;
return Object.entries(snapshot.connections).some(
([receiverUid, status]) => receiverUid === uid && status === "accepted"
);
}
// export async function DB.getLocalTagPB(tagId) {
// function cont() {
// let ret = 0;

View file

@ -75,6 +75,28 @@ export function update(): void {
}
);
}
updateFriendRequestsIndicator();
}
export function updateFriendRequestsIndicator(): void {
const friends = getSnapshot()?.connections;
if (friends !== undefined) {
const pendingFriendRequests = Object.values(friends).filter(
(it) => it === "incoming"
).length;
if (pendingFriendRequests > 0) {
$("nav .view-account > .notificationBubble").removeClass("hidden");
$("nav .goToFriends > .notificationBubble")
.removeClass("hidden")
.text(pendingFriendRequests);
return;
}
}
$("nav .view-account > .notificationBubble").addClass("hidden");
$("nav .goToFriends > .notificationBubble").addClass("hidden");
}
const coarse = window.matchMedia("(pointer:coarse)")?.matches;

View file

@ -0,0 +1,107 @@
import * as Notifications from "../../elements/notifications";
import { Connection } from "@monkeytype/schemas/connections";
import Ape from "../../ape";
import { format } from "date-fns/format";
import { isAuthenticated } from "../../firebase";
import { getReceiverUid } from "../../pages/friends";
import * as DB from "../../db";
import { updateFriendRequestsIndicator } from "../account-button";
let blockedUsers: Connection[] = [];
const element = $("#pageAccountSettings .tab[data-tab='blockedUsers']");
async function getData(): Promise<boolean> {
showLoaderRow();
if (!isAuthenticated()) {
blockedUsers = [];
return false;
}
const response = await Ape.connections.get({
query: { status: "blocked", type: "incoming" },
});
if (response.status !== 200) {
blockedUsers = [];
Notifications.add(
"Error getting blocked users: " + response.body.message,
-1
);
return false;
}
blockedUsers = response.body.data;
return true;
}
export async function update(): Promise<void> {
await getData();
refreshList();
}
function showLoaderRow(): void {
const table = element.find("table tbody");
table.empty();
table.append(
"<tr><td colspan='3' style='text-align: center;font-size:1rem;'><i class='fas fa-spin fa-circle-notch'></i></td></tr>"
);
}
function refreshList(): void {
const table = element.find("table tbody");
table.empty();
if (blockedUsers.length === 0) {
table.append(
"<tr><td colspan='3' style='text-align: center;'>No blocked users</td></tr>"
);
return;
}
const content = blockedUsers.map(
(blocked) => `
<tr data-id="${blocked._id}" data-uid="${getReceiverUid(blocked)}">
<td><a href="${location.origin}/profile/${
blocked.initiatorUid
}?isUid" router-link>${blocked.initiatorName}</a></td>
<td>${format(new Date(blocked.lastModified), "dd MMM yyyy HH:mm")}</td>
<td>
<button class="delete">
<i class="fas fa-fw fa-trash-alt"></i>
</button>
</td>
</tr>
`
);
table.append(content.join());
}
element.on("click", "table button.delete", async (e) => {
const row = (e.target as HTMLElement).closest("tr") as HTMLElement;
const id = row.dataset["id"];
if (id === undefined) {
throw new Error("Cannot find id of target.");
}
row.querySelectorAll("button").forEach((button) => (button.disabled = true));
const response = await Ape.connections.delete({ params: { id } });
if (response.status !== 200) {
Notifications.add(`Cannot unblock user: ${response.body.message}`, -1);
} else {
blockedUsers = blockedUsers.filter((it) => it._id !== id);
refreshList();
const snapshot = DB.getSnapshot();
if (snapshot) {
const uid = row.dataset["uid"];
if (uid === undefined) {
throw new Error("Cannot find uid of target.");
}
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete, @typescript-eslint/no-unsafe-member-access
delete snapshot.connections[uid];
updateFriendRequestsIndicator();
}
}
});

View file

@ -1,5 +1,5 @@
import * as DB from "../db";
import { format } from "date-fns/format";
import { format as dateFormat } from "date-fns/format";
import { differenceInDays } from "date-fns/differenceInDays";
import * as Misc from "../utils/misc";
import * as Numbers from "@monkeytype/util/numbers";
@ -11,12 +11,15 @@ import * as ActivePage from "../states/active-page";
import { formatDistanceToNowStrict } from "date-fns/formatDistanceToNowStrict";
import { getHtmlByUserFlags } from "../controllers/user-flag-controller";
import Format from "../utils/format";
import { UserProfile, RankAndCount } from "@monkeytype/schemas/users";
import { abbreviateNumber, convertRemToPixels } from "../utils/numbers";
import { UserProfile } from "@monkeytype/schemas/users";
import { convertRemToPixels } from "../utils/numbers";
import { secondsToString } from "../utils/date-and-time";
import { getAuthenticatedUser } from "../firebase";
import { Snapshot } from "../constants/default-snapshot";
import { getAvatarElement } from "../utils/discord-avatar";
import { formatXp } from "../utils/levels";
import { formatTopPercentage } from "../utils/misc";
import { get as getServerConfiguration } from "../ape/server-configuration";
type ProfileViewPaths = "profile" | "account";
type UserProfileOrSnapshot = UserProfile | Snapshot;
@ -68,7 +71,11 @@ export async function update(
}
details.find(".name").text(profile.name);
details.find(".userFlags").html(getHtmlByUserFlags(profile));
details
.find(".userFlags")
.html(
getHtmlByUserFlags({ ...profile, isFriend: DB.isFriend(profile.uid) })
);
if (profile.lbOptOut === true) {
if (where === "profile") {
@ -87,7 +94,8 @@ export async function update(
updateNameFontSize(where);
}, 10);
const joinedText = "Joined " + format(profile.addedAt ?? 0, "dd MMM yyyy");
const joinedText =
"Joined " + dateFormat(profile.addedAt ?? 0, "dd MMM yyyy");
const creationDate = new Date(profile.addedAt);
const diffDays = differenceInDays(new Date(), creationDate);
const balloonText = `${diffDays} day${diffDays !== 1 ? "s" : ""} ago`;
@ -184,21 +192,9 @@ export async function update(
.attr("aria-label", hoverText)
.attr("data-balloon-break", "");
let completedPercentage = "";
let restartRatio = "";
if (
profile.typingStats.completedTests !== undefined &&
profile.typingStats.startedTests !== undefined
) {
completedPercentage = Math.floor(
(profile.typingStats.completedTests / profile.typingStats.startedTests) *
100
).toString();
restartRatio = (
(profile.typingStats.startedTests - profile.typingStats.completedTests) /
profile.typingStats.completedTests
).toFixed(1);
}
const { completedPercentage, restartRatio } = Misc.formatTypingStatsRatio(
profile.typingStats
);
const typingStatsEl = details.find(".typingStats");
typingStatsEl
@ -326,8 +322,6 @@ export async function update(
profileElement.find(".userReportButton").removeClass("hidden");
}
//structure
const bioAndKey = bio || keyboard;
if (!bio) {
@ -371,6 +365,8 @@ export async function update(
} else if (socials && bioAndKey) {
details.addClass("both");
}
updateFriendRequestButton();
}
export function updateXp(
@ -439,6 +435,26 @@ export function updateNameFontSize(where: ProfileViewPaths): void {
nameField.style.fontSize = `${finalFontSize}px`;
}
export function updateFriendRequestButton(): void {
const myUid = getAuthenticatedUser()?.uid;
const profileUid = document
.querySelector(".profile")
?.getAttribute("uid") as string;
const button = document.querySelector(".profile .addFriendButton");
const myProfile = myUid === profileUid;
const hasRequest = DB.getSnapshot()?.connections[profileUid] !== undefined;
const featureEnabled = getServerConfiguration()?.connections.enabled;
if (!featureEnabled || myUid === undefined || myProfile) {
button?.classList.add("hidden");
} else if (hasRequest) {
button?.classList.add("disabled");
} else {
button?.classList.remove("disabled");
button?.classList.remove("hidden");
}
}
const throttledEvent = throttle(1000, () => {
const activePage = ActivePage.get();
if (activePage && ["account", "profile"].includes(activePage)) {
@ -449,17 +465,3 @@ const throttledEvent = throttle(1000, () => {
$(window).on("resize", () => {
throttledEvent();
});
function formatTopPercentage(lbRank: RankAndCount): string {
if (lbRank.rank === undefined) return "-";
if (lbRank.rank === 1) return "GOAT";
return "Top " + Numbers.roundTo2((lbRank.rank / lbRank.count) * 100) + "%";
}
function formatXp(xp: number): string {
if (xp < 1000) {
return Math.round(xp).toString();
} else {
return abbreviateNumber(xp);
}
}

View file

@ -34,9 +34,9 @@ export function init(
update(element, calendar);
}
export function clear(element: HTMLElement): void {
element.classList.add("hidden");
element.querySelector(".activity")?.replaceChildren();
export function clear(element?: HTMLElement): void {
element?.classList.add("hidden");
element?.querySelector(".activity")?.replaceChildren();
}
function update(element: HTMLElement, calendar?: TestActivityCalendar): void {

View file

@ -155,13 +155,15 @@ function disableInput(): void {
validateWithIndicator(nameInputEl, {
schema: UserNameSchema,
isValid: async (name: string) => {
const checkNameResponse = (
await Ape.users.getNameAvailability({
params: { name: name },
})
).status;
const checkNameResponse = await Ape.users.getNameAvailability({
params: { name: name },
});
return checkNameResponse === 200 ? true : "Name not available";
return (
(checkNameResponse.status === 200 &&
checkNameResponse.body.data.available) ||
"Name not available"
);
},
debounceDelay: 1000,
callback: (result) => {

View file

@ -480,13 +480,15 @@ list.updateName = new SimpleModal({
validation: {
schema: UserNameSchema,
isValid: async (newName: string) => {
const checkNameResponse = (
await Ape.users.getNameAvailability({
params: { name: newName },
})
).status;
const checkNameResponse = await Ape.users.getNameAvailability({
params: { name: newName },
});
return checkNameResponse === 200 ? true : "Name not available";
return (
(checkNameResponse.status === 200 &&
checkNameResponse.body.data.available) ||
"Name not available"
);
},
debounceDelay: 1000,
},

View file

@ -8,6 +8,7 @@ import Ape from "../ape";
import * as StreakHourOffsetModal from "../modals/streak-hour-offset";
import * as Loader from "../elements/loader";
import * as ApeKeyTable from "../elements/account-settings/ape-key-table";
import * as BlockedUserTable from "../elements/account-settings/blocked-user-table";
import * as Notifications from "../elements/notifications";
import { z } from "zod";
import * as AuthEvent from "../observables/auth-event";
@ -15,7 +16,13 @@ import * as AuthEvent from "../observables/auth-event";
const pageElement = $(".page.pageAccountSettings");
const StateSchema = z.object({
tab: z.enum(["authentication", "account", "apeKeys", "dangerZone"]),
tab: z.enum([
"authentication",
"account",
"apeKeys",
"dangerZone",
"blockedUsers",
]),
});
type State = z.infer<typeof StateSchema>;
@ -182,6 +189,7 @@ export function updateUI(): void {
updateIntegrationSections();
updateAccountSections();
void ApeKeyTable.update(updateUI);
void BlockedUserTable.update();
updateTabs();
page.setUrlParams(state);
}

View file

@ -1318,7 +1318,7 @@ ConfigEvent.subscribe((eventKey) => {
}
});
export const page = new Page({
export const page = new Page<undefined>({
id: "account",
element: $(".page.pageAccount"),
path: "/account",

View file

@ -0,0 +1,563 @@
import Page from "./page";
import * as Skeleton from "../utils/skeleton";
import { SimpleModal } from "../utils/simple-modal";
import Ape from "../ape";
import {
intervalToDuration,
format as dateFormat,
formatDuration,
formatDistanceToNow,
format,
} from "date-fns";
import * as Notifications from "../elements/notifications";
import { isSafeNumber } from "@monkeytype/util/numbers";
import { getHTMLById as getBadgeHTMLbyId } from "../controllers/badge-controller";
import { formatXp, getXpDetails } from "../utils/levels";
import { secondsToString } from "../utils/date-and-time";
import { PersonalBest } from "@monkeytype/schemas/shared";
import Format from "../utils/format";
import { getHtmlByUserFlags } from "../controllers/user-flag-controller";
import { SortedTable } from "../utils/sorted-table";
import { getAvatarElement } from "../utils/discord-avatar";
import { formatTypingStatsRatio } from "../utils/misc";
import { getLanguageDisplayString } from "../utils/strings";
import * as DB from "../db";
import { getAuthenticatedUser } from "../firebase";
import * as ServerConfiguration from "../ape/server-configuration";
import * as AuthEvent from "../observables/auth-event";
import { Connection } from "@monkeytype/schemas/connections";
import { Friend } from "@monkeytype/schemas/users";
import * as Loader from "../elements/loader";
const pageElement = $(".page.pageFriends");
let friendsTable: SortedTable<Friend> | undefined = undefined;
let pendingRequests: Connection[] | undefined;
let friendsList: Friend[] | undefined;
export function getReceiverUid(
connection: Pick<Connection, "initiatorUid" | "receiverUid">
): string {
const me = getAuthenticatedUser();
if (me === null)
throw new Error("expected to be authenticated in getReceiverUid");
if (me.uid === connection.initiatorUid) return connection.receiverUid;
return connection.initiatorUid;
}
export async function addFriend(receiverName: string): Promise<true | string> {
const result = await Ape.connections.create({ body: { receiverName } });
if (result.status !== 200) {
return `Friend request failed: ${result.body.message}`;
} else {
const snapshot = DB.getSnapshot();
if (snapshot !== undefined) {
const receiverUid = getReceiverUid(result.body.data);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
snapshot.connections[receiverUid] = result.body.data.status;
updatePendingConnections();
}
return true;
}
}
const addFriendModal = new SimpleModal({
id: "addFriend",
title: "Add a friend",
inputs: [
{
placeholder: "user name",
type: "text",
initVal: "",
validation: {
isValid: async (name: string) => {
const checkNameResponse = await Ape.users.getNameAvailability({
params: { name: name },
});
return (
(checkNameResponse.status === 200 &&
!checkNameResponse.body.data.available) ||
"Unknown user"
);
},
debounceDelay: 1000,
},
},
],
buttonText: "request",
onlineOnly: true,
execFn: async (_thisPopup, receiverName) => {
const result = await addFriend(receiverName);
if (result === true) {
return { status: 1, message: `Request send to ${receiverName}` };
}
let status: -1 | 0 | 1 = -1;
let message: string = "Unknown error";
if (result.includes("already exists")) {
status = 0;
message = `You are already friends with ${receiverName}`;
} else if (result.includes("request already sent")) {
status = 0;
message = `You have already sent a friend request to ${receiverName}`;
} else if (result.includes("blocked by initiator")) {
status = 0;
message = `You have blocked ${receiverName}`;
} else if (result.includes("blocked by receiver")) {
status = 0;
message = `${receiverName} has blocked you`;
}
return { status, message, alwaysHide: true };
},
});
const removeFriendModal = new SimpleModal({
id: "confirmUnfriend",
title: "Remove friend",
buttonText: "remove friend",
text: "Are you sure you want to remove as a friend?",
beforeInitFn: (thisPopup) => {
thisPopup.text = `Are you sure you want to remove ${thisPopup.parameters[1]} as a friend?`;
},
execFn: async (thisPopup) => {
const connectionId = thisPopup.parameters[0] as string;
const result = await Ape.connections.delete({
params: { id: connectionId },
});
if (result.status !== 200) {
return { status: -1, message: result.body.message };
} else {
friendsList = friendsList?.filter(
(it) => it.connectionId !== connectionId
);
friendsTable?.setData(friendsList ?? []);
friendsTable?.updateBody();
return { status: 1, message: `Friend removed` };
}
},
});
async function fetchPendingConnections(): Promise<void> {
const result = await Ape.connections.get({
query: { status: "pending", type: "incoming" },
});
if (result.status !== 200) {
Notifications.add("Error getting connections: " + result.body.message, -1);
pendingRequests = undefined;
} else {
pendingRequests = result.body.data;
DB.mergeConnections(pendingRequests);
}
}
function updatePendingConnections(): void {
$(".pageFriends .pendingRequests").addClass("hidden");
if (pendingRequests === undefined || pendingRequests.length === 0) {
$(".pageFriends .pendingRequests").addClass("hidden");
} else {
$(".pageFriends .pendingRequests").removeClass("hidden");
const html = pendingRequests
.map(
(item) => `<tr data-id="${
item._id
}" data-receiver-uid="${getReceiverUid(item)}">
<td><a href="${location.origin}/profile/${
item.initiatorUid
}?isUid" router-link>${item.initiatorName}</a></td>
<td>
<span data-balloon-pos="up" aria-label="since ${format(
item.lastModified,
"dd MMM yyyy HH:mm"
)}">
${formatAge(item.lastModified)} ago
<span>
</td>
<td class="actions">
<button class="accepted" aria-label="accept" data-balloon-pos="up">
<i class="fas fa-check fa-fw"></i>
</button>
<button class="rejected" aria-label="reject" data-balloon-pos="up">
<i class="fas fa-times fa-fw"></i>
</button>
<button class="blocked" aria-label="block" data-balloon-pos="up">
<i class="fas fa-shield-alt fa-fw"></i>
</button>
</td>
</tr>`
)
.join("\n");
$(".pageFriends .pendingRequests tbody").html(html);
}
}
async function fetchFriends(): Promise<void> {
const result = await Ape.users.getFriends();
if (result.status !== 200) {
Notifications.add("Error getting friends: " + result.body.message, -1);
friendsList = undefined;
} else {
friendsList = result.body.data;
}
}
function updateFriends(): void {
$(".pageFriends .friends .nodata").addClass("hidden");
$(".pageFriends .friends table").addClass("hidden");
$(".pageFriends .friends .error").addClass("hidden");
if (friendsList === undefined || friendsList.length === 0) {
$(".pageFriends .friends table").addClass("hidden");
$(".pageFriends .friends .nodata").removeClass("hidden");
} else {
$(".pageFriends .friends table").removeClass("hidden");
$(".pageFriends .friends .nodata").addClass("hidden");
if (friendsTable === undefined) {
friendsTable = new SortedTable<Friend>({
table: ".pageFriends .friends table",
data: friendsList,
buildRow: buildFriendRow,
initialSort: { property: "name", descending: false },
});
} else {
friendsTable.setData(friendsList);
}
friendsTable.updateBody();
}
}
function buildFriendRow(entry: Friend): HTMLTableRowElement {
const xpDetails = getXpDetails(entry.xp ?? 0);
const testStats = formatTypingStatsRatio(entry);
const top15 = formatPb(entry.top15);
const top60 = formatPb(entry.top60);
const element = document.createElement("tr");
element.dataset["connectionId"] = entry.connectionId;
const isMe = entry.uid === getAuthenticatedUser()?.uid;
let actions = "";
if (isMe) {
element.classList.add("me");
} else {
actions = `<button class="remove">
<i class="fas fa-user-times fa-fw"></i>
</button>`;
}
element.innerHTML = `<tr>
<td>
<div class="avatarNameBadge">
<div class="avatarPlaceholder"></div>
<a href="${location.origin}/profile/${
entry.uid
}?isUid" class="entryName" uid=${entry.uid} router-link>${
entry.name
}</a> <div class="flagsAndBadge">
${getHtmlByUserFlags(entry)}
${
isSafeNumber(entry.badgeId)
? getBadgeHTMLbyId(entry.badgeId)
: ""
}
</div>
</div>
</td>
<td><span data-balloon-pos="up" aria-label="${
entry.lastModified !== undefined
? "since " + format(entry.lastModified, "dd MMM yyyy HH:mm")
: ""
}">${
entry.lastModified !== undefined
? formatAge(entry.lastModified, "short")
: "-"
}</span></td>
<td><span aria-label="total xp: ${
isSafeNumber(entry.xp) ? formatXp(entry.xp) : ""
}" data-balloon-pos="up">
${xpDetails.level}
</span></td>
<td><span aria-label="${testStats.completedPercentage}% (${
testStats.restartRatio
} restarts per completed test)" data-balloon-pos="up">${
entry.completedTests
}/${entry.startedTests}</span></td>
<td>${secondsToString(
Math.round(entry.timeTyping ?? 0),
true,
true
)}</td>
<td><span aria-label="${formatStreak(
entry.streak?.maxLength,
"max streak"
)}" data-balloon-pos="up">
${formatStreak(entry.streak?.length)}
</span></td>
<td class="small"><span aria-label="${
top15?.details
}" data-balloon-pos="up" data-balloon-break="">${
top15?.wpm ?? "-"
}<div class="sub">${top15?.acc ?? "-"}</div><span></td>
<td class="small"><span aria-label="${
top60?.details
}" data-balloon-pos="up" data-balloon-break="">${
top60?.wpm ?? "-"
}<div class="sub">${top60?.acc ?? "-"}</div></span></td>
<td class="actions">
${actions}
</td>
</tr>`;
element
.querySelector(".avatarPlaceholder")
?.replaceWith(getAvatarElement(entry));
return element;
}
function formatAge(
timestamp: number | undefined,
format?: "short" | "full"
): string {
if (timestamp === undefined) return "";
let formatted = "";
const duration = intervalToDuration({ start: timestamp, end: Date.now() });
if (format === undefined || format === "full") {
formatted = formatDuration(duration, {
format: ["years", "months", "days", "hours", "minutes"],
});
} else {
formatted = formatDistanceToNow(timestamp);
}
return formatted !== "" ? formatted : "less then a minute";
}
function formatPb(entry?: PersonalBest):
| {
wpm: string;
acc: string;
raw: string;
con: string;
details: string;
}
| undefined {
if (entry === undefined) {
return undefined;
}
const result = {
wpm: Format.typingSpeed(entry.wpm, { showDecimalPlaces: true }),
acc: Format.percentage(entry.acc, { showDecimalPlaces: true }),
raw: Format.typingSpeed(entry.raw, { showDecimalPlaces: true }),
con: Format.percentage(entry.consistency, { showDecimalPlaces: true }),
details: "",
};
result.details = [
`${getLanguageDisplayString(entry.language)}`,
`${result.wpm} wpm`,
`${result.acc} acc`,
`${result.raw} raw`,
`${result.con} con`,
`${dateFormat(entry.timestamp, "dd MMM yyyy")}`,
].join("\n");
return result;
}
function formatStreak(length?: number, prefix?: string): string {
if (length === 1) return "-";
return isSafeNumber(length)
? `${prefix !== undefined ? prefix + " " : ""}${length} days`
: "-";
}
$(".pageFriends button.friendAdd").on("click", () => {
addFriendModal.show(undefined, {});
});
// need to set the listener for action buttons on the table because the table content is getting replaced
$(".pageFriends .pendingRequests table").on("click", async (e) => {
const action = Array.from(e.target.classList).find((it) =>
["accepted", "rejected", "blocked"].includes(it)
) as "accepted" | "rejected" | "blocked";
if (action === undefined) return;
const row = e.target.closest("tr") as HTMLElement;
const id = row.dataset["id"];
if (id === undefined) {
throw new Error("Cannot find id of target.");
}
row.querySelectorAll("button").forEach((button) => (button.disabled = true));
Loader.show();
const result =
action === "rejected"
? await Ape.connections.delete({
params: { id },
})
: await Ape.connections.update({
params: { id },
body: { status: action },
});
Loader.hide();
if (result.status !== 200) {
Notifications.add(
`Cannot update friend request: ${result.body.message}`,
-1
);
} else {
//remove from cache
pendingRequests = pendingRequests?.filter((it) => it._id !== id);
updatePendingConnections();
const snapshot = DB.getSnapshot();
if (snapshot) {
const receiverUid = row.dataset["receiverUid"];
if (receiverUid === undefined) {
throw new Error("Cannot find receiverUid of target.");
}
if (action === "rejected") {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete, @typescript-eslint/no-unsafe-member-access
delete snapshot.connections[receiverUid];
} else {
snapshot.connections[receiverUid] = action;
}
DB.setSnapshot(snapshot);
}
if (action === "blocked") {
Notifications.add(`User has been blocked`, 0);
}
if (action === "accepted") {
Notifications.add(`Request accepted`, 1);
}
if (action === "rejected") {
Notifications.add(`Request rejected`, 0);
}
if (action === "accepted") {
showSpinner();
await fetchFriends();
updateFriends();
hideSpinner();
}
}
});
// need to set the listener for action buttons on the table because the table content is getting replaced
$(".pageFriends .friends table").on("click", async (e) => {
const action = Array.from(e.target.classList).find((it) =>
["remove"].includes(it)
);
if (action === undefined) return;
const row = e.target.closest("tr") as HTMLElement;
const connectionId = row.dataset["connectionId"];
if (connectionId === undefined) {
throw new Error("Cannot find id of target.");
}
if (action === "remove") {
const name = row.querySelector("a.entryName")?.textContent ?? "";
removeFriendModal.show([connectionId, name], {});
}
});
function showSpinner(): void {
document.querySelector(".friends .spinner")?.classList.remove("hidden");
}
function hideSpinner(): void {
document.querySelector(".friends .spinner")?.classList.add("hidden");
}
function update(): void {
updatePendingConnections();
updateFriends();
}
export const page = new Page<undefined>({
id: "friends",
display: "Friends",
element: pageElement,
path: "/friends",
loadingOptions: {
loadingMode: () => {
if (!getAuthenticatedUser()) {
return "none";
}
const hasCache =
friendsList !== undefined && pendingRequests !== undefined;
if (hasCache) {
return {
mode: "async",
beforeLoading: showSpinner,
afterLoading: () => {
hideSpinner();
update();
},
};
} else {
return "sync";
}
},
loadingPromise: async () => {
await ServerConfiguration.configurationPromise;
const serverConfig = ServerConfiguration.get();
if (!serverConfig?.connections.enabled) {
throw new Error("Connectins are disabled.");
}
await Promise.all([fetchPendingConnections(), fetchFriends()]);
},
style: "bar",
keyframes: [
{ percentage: 50, durationMs: 1500, text: "Downloading friends..." },
{
percentage: 50,
durationMs: 1500,
text: "Downloading friend requests...",
},
],
},
afterHide: async (): Promise<void> => {
Skeleton.remove("pageFriends");
},
beforeShow: async (): Promise<void> => {
Skeleton.append("pageFriends", "main");
update();
},
});
$(() => {
Skeleton.save("pageFriends");
});
AuthEvent.subscribe((event) => {
if (event.type === "authStateChanged" && !event.data.isUserSignedIn) {
pendingRequests = undefined;
friendsList = undefined;
}
});

View file

@ -434,7 +434,10 @@ function buildTableRow(entry: LeaderboardEntry, me = false): HTMLElement {
entry.uid
}?isUid" class="entryName" uid=${entry.uid} router-link>${entry.name}</a>
<div class="flagsAndBadge">
${getHtmlByUserFlags(entry)}
${getHtmlByUserFlags({
...entry,
isFriend: DB.isFriend(entry.uid),
})}
${
isSafeNumber(entry.badgeId) ? getBadgeHTMLbyId(entry.badgeId) : ""
}
@ -489,7 +492,10 @@ function buildWeeklyTableRow(
entry.uid
}?isUid" class="entryName" uid=${entry.uid} router-link>${entry.name}</a>
<div class="flagsAndBadge">
${getHtmlByUserFlags(entry)}
${getHtmlByUserFlags({
...entry,
isFriend: DB.isFriend(entry.uid),
})}
${
isSafeNumber(entry.badgeId) ? getBadgeHTMLbyId(entry.badgeId) : ""
}
@ -1417,7 +1423,7 @@ export const page = new PageWithUrlParams({
stopTimer();
},
beforeShow: async (options): Promise<void> => {
await ServerConfiguration.configPromise;
await ServerConfiguration.configurationPromise;
Skeleton.append("pageLeaderboards", "main");
await updateValidDailyLeaderboards();
await appendModeAndLanguageButtons();

View file

@ -74,13 +74,15 @@ const nameInputEl = document.querySelector(
validateWithIndicator(nameInputEl, {
schema: UserNameSchema,
isValid: async (name: string) => {
const checkNameResponse = (
await Ape.users.getNameAvailability({
params: { name: name },
})
).status;
const checkNameResponse = await Ape.users.getNameAvailability({
params: { name: name },
});
return checkNameResponse === 200 ? true : "Name not available";
return (
(checkNameResponse.status === 200 &&
checkNameResponse.body.data.available) ||
"Name not available"
);
},
debounceDelay: 1000,
callback: (result) => {

View file

@ -15,7 +15,8 @@ export type PageName =
| "profileSearch"
| "404"
| "accountSettings"
| "leaderboards";
| "leaderboards"
| "friends";
type Options<T> = {
params?: Record<string, string>;

View file

@ -11,6 +11,7 @@ import { PersonalBests } from "@monkeytype/schemas/shared";
import * as TestActivity from "../elements/test-activity";
import { TestActivityCalendar } from "../elements/test-activity-calendar";
import { getFirstDayOfTheWeek } from "../utils/date-and-time";
import { addFriend } from "./friends";
const firstDayOfTheWeek = getFirstDayOfTheWeek();
@ -74,12 +75,19 @@ function reset(): void {
</div>
<div class="buttonGroup">
<button
class="userReportButton"
class="userReportButton hidden"
data-balloon-pos="left"
aria-label="Report user"
>
<i class="fas fa-flag"></i>
</button>
<button
class="addFriendButton hidden"
data-balloon-pos="left"
aria-label="Send friend request"
>
<i class="fas fa-user-plus"></i>
</button>
</div>
</div>
<div class="leaderboardsPositions">
@ -237,6 +245,19 @@ $(".page.pageProfile").on("click", ".profile .userReportButton", () => {
void UserReportModal.show({ uid, name, lbOptOut });
});
$(".page.pageProfile").on("click", ".profile .addFriendButton", async () => {
const friendName = $(".page.pageProfile .profile").attr("name") ?? "";
const result = await addFriend(friendName);
if (result === true) {
Notifications.add(`Request send to ${friendName}`);
$(".profile .details .addFriendButton").addClass("disabled");
} else {
Notifications.add(result, -1);
}
});
export const page = new Page<undefined | UserProfile>({
id: "profile",
element: $(".page.pageProfile"),

View file

@ -36,6 +36,9 @@ $(async (): Promise<void> => {
$(".login").addClass("hidden");
$(".disabledNotification").removeClass("hidden");
}
if (!ServerConfiguration.get()?.connections.enabled) {
$(".accountButtonAndMenu .goToFriends").addClass("hidden");
}
});
}
MonkeyPower.init();

View file

@ -1,3 +1,5 @@
import { abbreviateNumber } from "./numbers";
/**
* Calculates the level based on the total XP.
* This is the inverse of the function getTotalXpToReachLevel()
@ -51,3 +53,11 @@ export function getXpDetails(totalXp: number): XPDetails {
levelMaxXp: getLevelMaxXp(level),
};
}
export function formatXp(xp: number): string {
if (xp < 1000) {
return Math.round(xp).toString();
} else {
return abbreviateNumber(xp);
}
}

View file

@ -4,6 +4,8 @@ import { lastElementFromArray } from "./arrays";
import { Config } from "@monkeytype/schemas/configs";
import { Mode, Mode2, PersonalBests } from "@monkeytype/schemas/shared";
import { Result } from "@monkeytype/schemas/results";
import { RankAndCount } from "@monkeytype/schemas/users";
import { roundTo2 } from "@monkeytype/util/numbers";
export function whorf(speed: number, wordlen: number): number {
return Math.min(
@ -761,6 +763,33 @@ export function scrollToCenterOrTop(el: HTMLElement | null): void {
});
}
export function formatTopPercentage(lbRank: RankAndCount): string {
if (lbRank.rank === undefined) return "-";
if (lbRank.rank === 1) return "GOAT";
return "Top " + roundTo2((lbRank.rank / lbRank.count) * 100) + "%";
}
export function formatTypingStatsRatio(stats: {
startedTests?: number;
completedTests?: number;
}): {
completedPercentage: string;
restartRatio: string;
} {
if (stats.completedTests === undefined || stats.startedTests === undefined) {
return { completedPercentage: "", restartRatio: "" };
}
return {
completedPercentage: Math.floor(
(stats.completedTests / stats.startedTests) * 100
).toString(),
restartRatio: (
(stats.startedTests - stats.completedTests) /
stats.completedTests
).toFixed(1),
};
}
export function addToGlobal(items: Record<string, unknown>): void {
for (const [name, item] of Object.entries(items)) {
//@ts-expect-error dev

View file

@ -86,6 +86,7 @@ export type ExecReturn = {
notificationOptions?: Notifications.AddNotificationOptions;
hideOptions?: HideOptions;
afterHide?: () => void;
alwaysHide?: boolean;
};
type FormInput = CommonInputType & {
@ -373,7 +374,7 @@ export class SimpleModal {
if (res.showNotification ?? true) {
Notifications.add(res.message, res.status, res.notificationOptions);
}
if (res.status === 1) {
if (res.status === 1 || res.alwaysHide) {
void this.hide(true, res.hideOptions).then(() => {
if (res.afterHide) {
res.afterHide();

View file

@ -0,0 +1,145 @@
type Sort = {
property: string;
descending: boolean;
};
type SortedTableOptions<T> = {
table: string;
data?: T[];
buildRow: (entry: T) => HTMLTableRowElement;
initialSort?: Sort;
};
export class SortedTable<T> {
protected data: { source: T; element?: HTMLTableRowElement }[];
private table: JQuery<HTMLTableElement>;
private buildRow: (entry: T) => HTMLTableRowElement;
private sort?: Sort;
constructor({ table, data, buildRow, initialSort }: SortedTableOptions<T>) {
this.table = $(table);
if (this.table === undefined) {
throw new Error(`No element found for ${table}`);
}
this.buildRow = buildRow;
this.data = [];
if (data !== undefined) {
this.setData(data);
}
if (initialSort !== undefined) {
this.sort = initialSort;
this.doSort();
}
//init headers
for (const col of this.table.find(`td[data-sort-property]`)) {
col.classList.add("sortable");
col.setAttribute("type", "button");
col.onclick = (e: MouseEvent) => {
const target = e.currentTarget as HTMLElement;
const property = target.dataset["sortProperty"] as string;
const defaultDirection =
target.dataset["sortDefaultDirection"] === "desc";
if (property === undefined) return;
if (this.sort === undefined || property !== this.sort.property) {
this.sort = { property, descending: defaultDirection };
} else {
this.sort.descending = !this.sort?.descending;
}
this.doSort();
this.updateBody();
};
}
}
public setSort(sort: Partial<Sort>): void {
this.sort = { ...this.sort, ...sort } as Sort;
this.doSort();
}
public setData(data: T[]): void {
this.data = data.map((source) => ({ source }));
this.doSort();
}
private doSort(): void {
if (this.sort === undefined) return;
const { property, descending } = this.sort;
// Removes styling from previous sorting requests:
this.table.find("thead td").removeClass("headerSorted");
this.table.find("thead td").children("i").remove();
this.table
.find(`thead td[data-sort-property="${property}"]`)
.addClass("headerSorted")
.append(
`<i class="fas ${
descending ? "fa-sort-down" : "fa-sort-up"
} aria-hidden="true"></i>`
);
this.data.sort((a, b) => {
const valA = getValueByPath(a.source, property);
const valB = getValueByPath(b.source, property);
let result = 0;
if (valA === undefined && valB !== undefined) {
return descending ? 1 : -1;
} else if (valA !== undefined && valB === undefined) {
return descending ? -1 : 1;
}
if (typeof valA === "string" && typeof valB === "string") {
result = valA.localeCompare(valB);
}
if (typeof valA === "number" && typeof valB === "number") {
result = valA - valB;
}
return descending ? -result : result;
});
}
public updateBody(): void {
const body = this.table.find("tbody");
body.empty();
body.append(
this.getData().map((data) => {
if (data.element === undefined) {
data.element = this.buildRow(data.source);
}
return data.element;
})
);
}
protected getData(): { source: T; element?: HTMLTableRowElement }[] {
return this.data;
}
}
function getValueByPath(obj: unknown, path: string): unknown {
return path.split(".").reduce((acc, key) => {
// oxlint-disable-next-line no-explicit-any
// @ts-expect-error this is fine
return acc !== null && acc !== undefined ? acc[key] : undefined;
}, obj);
}
export class SortedTableWithLimit<T> extends SortedTable<T> {
private limit: number;
constructor(options: SortedTableOptions<T> & { limit: number }) {
super(options);
this.limit = options.limit;
}
protected override getData(): { source: T; element?: HTMLTableRowElement }[] {
return this.data.slice(0, this.limit);
}
public setLimit(limit: number): void {
this.limit = limit;
}
}

View file

@ -0,0 +1,136 @@
import { initContract } from "@ts-rest/core";
import {
ConnectionSchema,
ConnectionStatusSchema,
ConnectionTypeSchema,
} from "@monkeytype/schemas/connections";
import { z } from "zod";
import {
CommonResponses,
meta,
MonkeyResponseSchema,
responseWithData,
} from "./util/api";
import { IdSchema } from "@monkeytype/schemas/util";
const c = initContract();
export const GetConnectionsResponseSchema = responseWithData(
z.array(ConnectionSchema)
);
export type GetConnectionsResponse = z.infer<
typeof GetConnectionsResponseSchema
>;
export const GetConnectionsQuerySchema = z.object({
status: z
.array(ConnectionStatusSchema)
.or(ConnectionStatusSchema.transform((it) => [it]))
.optional(),
type: z
.array(ConnectionTypeSchema)
.or(ConnectionTypeSchema.transform((it) => [it]))
.optional(),
});
export type GetConnectionsQuery = z.infer<typeof GetConnectionsQuerySchema>;
export const CreateConnectionRequestSchema = ConnectionSchema.pick({
receiverName: true,
});
export type CreateConnectionRequest = z.infer<
typeof CreateConnectionRequestSchema
>;
export const CreateConnectionResponseSchema =
responseWithData(ConnectionSchema);
export type CreateConnectionResponse = z.infer<
typeof CreateConnectionResponseSchema
>;
export const IdPathParamsSchema = z.object({
id: IdSchema,
});
export type IdPathParams = z.infer<typeof IdPathParamsSchema>;
export const UpdateConnectionRequestSchema = z.object({
status: ConnectionStatusSchema.exclude(["pending"]),
});
export type UpdateConnectionRequest = z.infer<
typeof UpdateConnectionRequestSchema
>;
export const connectionsContract = c.router(
{
get: {
summary: "get connections",
description: "Get connections of the current user",
method: "GET",
path: "/",
query: GetConnectionsQuerySchema.strict(),
responses: {
200: GetConnectionsResponseSchema,
},
metadata: meta({
rateLimit: "connectionGet",
}),
},
create: {
summary: "create connection",
description: "Request a connection to a user ",
method: "POST",
path: "/",
body: CreateConnectionRequestSchema.strict(),
responses: {
200: CreateConnectionResponseSchema,
404: MonkeyResponseSchema.describe("ReceiverUid unknown"),
409: MonkeyResponseSchema.describe(
"Duplicate connection, blocked or max connections reached"
),
},
metadata: meta({
rateLimit: "connectionCreate",
}),
},
delete: {
summary: "delete connection",
description: "Delete a connection",
method: "DELETE",
path: "/:id",
pathParams: IdPathParamsSchema.strict(),
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "connectionDelete",
}),
},
update: {
summary: "update connection",
description: "Update a connection status",
method: "PATCH",
path: "/:id",
pathParams: IdPathParamsSchema.strict(),
body: UpdateConnectionRequestSchema.strict(),
responses: {
200: MonkeyResponseSchema,
},
metadata: meta({
rateLimit: "connectionUpdate",
}),
},
},
{
pathPrefix: "/connections",
strictStatusCodes: true,
metadata: meta({
openApiTags: "connections",
requireConfiguration: {
path: "connections.enabled",
invalidMessage: "Connections are not available at this time.",
},
}),
commonResponses: CommonResponses,
}
);

View file

@ -12,6 +12,7 @@ import { devContract } from "./dev";
import { usersContract } from "./users";
import { quotesContract } from "./quotes";
import { webhooksContract } from "./webhooks";
import { connectionsContract } from "./connections";
const c = initContract();
@ -29,11 +30,12 @@ export const contract = c.router({
users: usersContract,
quotes: quotesContract,
webhooks: webhooksContract,
connections: connectionsContract,
});
/**
* Whenever there is a breaking change with old frontend clients increase this number.
* This will inform the frontend to refresh.
*/
export const COMPATIBILITY_CHECK = 3;
export const COMPATIBILITY_CHECK = 4;
export const COMPATIBILITY_CHECK_HEADER = "X-Compatibility-Check";

View file

@ -346,6 +346,11 @@ export const limits = {
max: 60,
},
userFriendGet: {
window: "hour",
max: 60,
},
// ApeKeys Routing
apeKeysGet: {
window: "hour",
@ -361,6 +366,26 @@ export const limits = {
window: "second",
max: 1,
},
connectionGet: {
window: "hour",
max: 60,
},
connectionCreate: {
window: "hour",
max: 60,
},
connectionDelete: {
window: "hour",
max: 60,
},
connectionUpdate: {
window: "hour",
max: 60,
},
} satisfies Record<string, RateLimitOptions>;
export type RateLimiterId = keyof typeof limits;

View file

@ -26,6 +26,7 @@ import {
UserTagSchema,
UserEmailSchema,
UserNameSchema,
FriendSchema,
} from "@monkeytype/schemas/users";
import {
Mode2Schema,
@ -58,6 +59,13 @@ export type CheckNamePathParameters = z.infer<
typeof CheckNamePathParametersSchema
>;
export const CheckNameResponseSchema = responseWithData(
z.object({
available: z.boolean(),
})
);
export type CheckNameResponse = z.infer<typeof CheckNameResponseSchema>;
export const UpdateUserNameRequestSchema = z.object({
name: UserNameSchema,
});
@ -320,6 +328,9 @@ export const GetStreakResponseSchema =
responseWithNullableData(UserStreakSchema);
export type GetStreakResponse = z.infer<typeof GetStreakResponseSchema>;
export const GetFriendsResponseSchema = responseWithData(z.array(FriendSchema));
export type GetFriendsResponse = z.infer<typeof GetFriendsResponseSchema>;
const c = initContract();
export const usersContract = c.router(
@ -360,8 +371,7 @@ export const usersContract = c.router(
path: "/checkName/:name",
pathParams: CheckNamePathParametersSchema.strict(),
responses: {
200: MonkeyResponseSchema.describe("Name is available"),
409: MonkeyResponseSchema.describe("Name is not available"),
200: CheckNameResponseSchema,
},
metadata: meta({
authenticationOptions: { isPublic: true },
@ -926,6 +936,22 @@ export const usersContract = c.router(
rateLimit: "userStreak",
}),
},
getFriends: {
summary: "get friends",
description: "get friends list",
method: "GET",
path: "/friends",
responses: {
200: GetFriendsResponseSchema,
},
metadata: meta({
rateLimit: "userFriendGet",
requireConfiguration: {
path: "connections.enabled",
invalidMessage: "Connections are not available at this time.",
},
}),
},
},
{
pathPrefix: "/users",

View file

@ -15,7 +15,8 @@ export type OpenApiTag =
| "development"
| "users"
| "quotes"
| "webhooks";
| "webhooks"
| "connections";
export type PermissionId =
| "quoteMod"

View file

@ -123,5 +123,9 @@ export const ConfigurationSchema = z.object({
xpRewardBrackets: z.array(RewardBracketSchema),
}),
}),
connections: z.object({
enabled: z.boolean(),
maxPerUser: z.number().int().nonnegative(),
}),
});
export type Configuration = z.infer<typeof ConfigurationSchema>;

View file

@ -0,0 +1,24 @@
import { z } from "zod";
import { IdSchema } from "./util";
export const ConnectionStatusSchema = z.enum([
"pending",
"accepted",
"blocked",
]);
export type ConnectionStatus = z.infer<typeof ConnectionStatusSchema>;
export const ConnectionTypeSchema = z.enum(["incoming", "outgoing"]);
export type ConnectionType = z.infer<typeof ConnectionTypeSchema>;
export const ConnectionSchema = z.object({
_id: IdSchema,
initiatorUid: IdSchema,
initiatorName: z.string(),
receiverUid: IdSchema,
receiverName: z.string(),
lastModified: z.number().int().nonnegative(),
status: ConnectionStatusSchema,
});
export type Connection = z.infer<typeof ConnectionSchema>;

View file

@ -9,9 +9,11 @@ import {
DefaultTimeModeSchema,
QuoteLengthSchema,
DifficultySchema,
PersonalBestSchema,
} from "./shared";
import { CustomThemeColorsSchema, FunboxNameSchema } from "./configs";
import { doesNotContainProfanity } from "./validation/validation";
import { ConnectionSchema } from "./connections";
const NoneFilterSchema = z.literal("none");
export const ResultFiltersSchema = z.object({
@ -382,3 +384,27 @@ export const PasswordSchema = z
message: "must contain at least one special character",
});
export type Password = z.infer<typeof PasswordSchema>;
export const FriendSchema = UserSchema.pick({
uid: true,
name: true,
discordId: true,
discordAvatar: true,
startedTests: true,
completedTests: true,
timeTyping: true,
xp: true,
banned: true,
lbOptOut: true,
})
.extend({
connectionId: IdSchema.optional(),
top15: PersonalBestSchema.optional(),
top60: PersonalBestSchema.optional(),
badgeId: z.number().int().optional(),
isPremium: z.boolean().optional(),
streak: UserStreakSchema.pick({ length: true, maxLength: true }),
})
.merge(ConnectionSchema.pick({ lastModified: true }).partial());
export type Friend = z.infer<typeof FriendSchema>;