`;
}
}
return buf;
}
// much more info redacted
async visualizePublicList(userid: ID) {
const friends: string[] = (await Chat.Friends.getFriends(userid) as any[]).map(f => f.friend);
let buf = `
${userid}'s friends:
`;
if (!friends.length) {
buf += `None.`;
return buf;
}
for (const friend of friends) {
buf += `- ${friend} `;
}
return buf;
}
displayFriend(userid: ID, login?: number) {
const user = Users.getExact(userid); // we want this to be exact
const name = Utils.escapeHTML(user ? user.name : userid);
const statusType = user?.connected ?
`\u25C9 ${STATUS_TITLES[user.statusType]}` :
'\u25CC Offline';
let buf = user ?
`${name} (${statusType})` :
Utils.html`${name} (${statusType})`;
buf += ` `;
const curUser = Users.get(userid); // might be an alt
if (user) {
if (user.userMessage) buf += Utils.html`Status: ${user.userMessage} `;
} else if (curUser && curUser.id !== userid) {
buf += `On an alternate account `;
}
if (login && typeof login === 'number' && !user?.connected) {
buf += `Last seen: `;
buf += ` (${Chat.toDurationString(Date.now() - login, { precision: 1 })} ago)`;
} else if (typeof login === 'string') {
buf += `${login}`;
}
buf = `
${buf}
`;
return toLink(buf);
}
checkCanUse(context: Chat.CommandContext | Chat.PageContext) {
const user = context.user;
if (!user.autoconfirmed) {
throw new Chat.ErrorMessage(context.tr`You must be autoconfirmed to use the friends feature.`);
}
if (user.locked || user.namelocked || user.semilocked || user.permalocked) {
throw new Chat.ErrorMessage(`You are locked, and so cannot use the friends feature.`);
}
if (!Config.usesqlitefriends || !Config.usesqlite) {
throw new Chat.ErrorMessage(`The friends list feature is currently disabled.`);
}
if (!Users.globalAuth.atLeast(user, Config.usesqlitefriends)) {
throw new Chat.ErrorMessage(`You are currently unable to use the friends feature.`);
}
}
request(user: User, receiver: ID) {
return Chat.Friends.request(user, receiver);
}
removeFriend(userid: ID, friendID: ID) {
return Chat.Friends.removeFriend(userid, friendID);
}
approveRequest(receiverID: ID, senderID: ID) {
return Chat.Friends.approveRequest(receiverID, senderID);
}
removeRequest(receiverID: ID, senderID: ID) {
return Chat.Friends.removeRequest(receiverID, senderID);
}
updateSpectatorLists(user: User) {
if (!user.friends) return; // probably should never happen
for (const id of user.friends) {
// should only work if theyre on that userid, since friends list is by userid
const curUser = Users.getExact(id);
if (curUser) {
for (const conn of curUser.connections) {
if (conn.openPages?.has('friends-spectate')) {
void Chat.parse('/friends view spectate', null, curUser, conn);
}
}
}
}
}
};
/** UI functions chiefly for the chat page. */
function toLink(buf: string) {
return buf.replace(/',
received: '',
all: '',
help: '',
settings: '',
spectate: '',
};
const titles: { [k: string]: string } = {
all: 'All Friends',
spectate: 'Spectate',
sent: 'Sent',
received: 'Received',
help: 'Help',
settings: 'Settings',
};
for (const page in titles) {
const title = titles[page];
const icon = icons[page];
if (page === type) {
buf.push(`${icon} ${user.tr(title)}`);
} else {
buf.push(`${icon} ${user.tr(title)}`);
}
}
const refresh = (
``
);
return `
${buf.join(' / ')}${refresh}
`;
}
export const commands: Chat.ChatCommands = {
unfriend(target) {
return this.parse(`/friend remove ${target}`);
},
friend: 'friends',
friendslist: 'friends',
friends: {
''(target) {
if (toID(target)) {
return this.parse(`/friend add ${target}`);
}
return this.parse(`/friends list`);
},
viewlist(target, room, user) {
Friends.checkCanUse(this);
target = toID(target);
if (!target) return this.errorReply(`Specify a user.`);
if (target === user.id) return this.parse(`/friends list`);
return this.parse(`/j view-friends-viewuser-${target}`);
},
request: 'add',
async add(target, room, user, connection) {
Friends.checkCanUse(this);
target = toID(target);
if (target.length > 18) {
return this.errorReply(this.tr`That name is too long - choose a valid name.`);
}
if (!target) return this.parse('/help friends');
await Friends.request(user, target as ID);
this.refreshPage('friends-sent');
return this.sendReply(`You sent a friend request to '${target}'.`);
},
unfriend: 'remove',
async remove(target, room, user) {
Friends.checkCanUse(this);
target = toID(target);
if (!target) return this.parse('/help friends');
await Friends.removeFriend(user.id, target as ID);
this.sendReply(`Removed friend '${target}'.`);
await Chat.Friends.updateUserCache(user);
this.refreshPage('friends-all');
const targetUser = Users.get(target);
if (targetUser) await Chat.Friends.updateUserCache(targetUser);
},
view(target) {
return this.parse(`/join view-friends-${target}`);
},
list() {
return this.parse(`/join view-friends-all`);
},
async accept(target, room, user, connection) {
Friends.checkCanUse(this);
target = toID(target);
if (user.settings.blockFriendRequests) {
return this.errorReply(this.tr`You are currently blocking friend requests, and so cannot accept your own.`);
}
if (!target) return this.parse('/help friends');
await Friends.approveRequest(user.id, target as ID);
const targetUser = Users.get(target);
sendPM(`You accepted a friend request from "${target}".`, user.id);
this.refreshPage('friends-received');
if (targetUser) {
sendPM(`/text ${user.name} accepted your friend request!`, targetUser.id);
sendPM(`/uhtmlchange sent-${targetUser.id},`, targetUser.id);
sendPM(`/uhtmlchange undo-${targetUser.id},`, targetUser.id);
}
await Chat.Friends.updateUserCache(user);
if (targetUser) await Chat.Friends.updateUserCache(targetUser);
},
deny: 'reject',
async reject(target, room, user, connection) {
Friends.checkCanUse(this);
target = toID(target);
if (!target) return this.parse('/help friends');
const res = await Friends.removeRequest(user.id, target as ID);
if (!res.changes) {
return this.errorReply(`You do not have a friend request pending from '${target}'.`);
}
this.refreshPage('friends-received');
return sendPM(`You denied a friend request from '${target}'.`, user.id);
},
toggle(target, room, user, connection) {
Friends.checkCanUse(this);
const setting = user.settings.blockFriendRequests;
target = target.trim();
if (this.meansYes(target)) {
if (!setting) return this.errorReply(this.tr`You already are allowing friend requests.`);
user.settings.blockFriendRequests = false;
this.sendReply(this.tr`You are now allowing friend requests.`);
} else if (this.meansNo(target)) {
if (setting) return this.errorReply(this.tr`You already are blocking incoming friend requests.`);
user.settings.blockFriendRequests = true;
this.sendReply(this.tr`You are now blocking incoming friend requests.`);
} else {
if (target) this.errorReply(this.tr`Unrecognized setting.`);
this.sendReply(
this.tr(setting ? `You are currently blocking friend requests.` : `You are not blocking friend requests.`)
);
}
this.refreshPage('friends-settings');
user.update();
},
async undorequest(target, room, user, connection) {
Friends.checkCanUse(this);
target = toID(target);
await Friends.removeRequest(target as ID, user.id);
this.refreshPage('friends-sent');
return sendPM(`You removed your friend request to '${target}'.`, user.id);
},
hidenotifs: 'viewnotifications',
hidenotifications: 'viewnotifications',
viewnotifs: 'viewnotifications',
viewnotifications(target, room, user, connection, cmd) {
// Friends.checkCanUse(this);
const setting = user.settings.allowFriendNotifications;
target = target.trim();
if (!cmd.includes('hide') || target && this.meansYes(target)) {
if (setting) return this.errorReply(this.tr(`You are already allowing friend notifications.`));
user.settings.allowFriendNotifications = true;
this.sendReply(this.tr(`You will now receive friend notifications.`));
} else if (cmd.includes('hide') || target && this.meansNo(target)) {
if (!setting) return this.errorReply(this.tr`You are already not receiving friend notifications.`);
user.settings.allowFriendNotifications = false;
this.sendReply(this.tr`You will not receive friend notifications.`);
} else {
if (target) this.errorReply(this.tr`Unrecognized setting.`);
this.sendReply(
this.tr(setting ? `You are currently allowing friend notifications.` : `Your friend notifications are disabled.`)
);
}
this.refreshPage('friends-settings');
user.update();
},
hidelogins: 'togglelogins',
showlogins: 'togglelogins',
async togglelogins(target, room, user, connection, cmd) {
Friends.checkCanUse(this);
const setting = user.settings.hideLogins;
if (cmd.includes('hide')) {
if (setting) return this.errorReply(this.tr`You are already hiding your logins from friends.`);
user.settings.hideLogins = true;
await Chat.Friends.hideLoginData(user.id);
this.sendReply(`You are now hiding your login times from your friends.`);
} else if (cmd.includes('show')) {
if (!setting) return this.errorReply(this.tr`You are already allowing friends to see your login times.`);
user.settings.hideLogins = false;
await Chat.Friends.allowLoginData(user.id);
this.sendReply(`You are now allowing your friends to see your login times.`);
} else {
return this.errorReply(`Invalid setting.`);
}
this.refreshPage('friends-settings');
user.update();
},
async listdisplay(target, room, user, connection) {
Friends.checkCanUse(this);
target = toID(target);
const { public_list: setting } = await Chat.Friends.getSettings(user.id);
if (this.meansYes(target)) {
if (setting) {
return this.errorReply(this.tr`You are already allowing other people to view your friends list.`);
}
await Chat.Friends.setHideList(user.id, true);
this.refreshPage('friends-settings');
return this.sendReply(this.tr`You are now allowing other people to view your friends list.`);
} else if (this.meansNo(target)) {
if (!setting) {
return this.errorReply(this.tr`You are already hiding your friends list.`);
}
await Chat.Friends.setHideList(user.id, false);
this.refreshPage('friends-settings');
return this.sendReply(this.tr`You are now hiding your friends list.`);
}
this.sendReply(`You are currently ${setting ? 'displaying' : 'hiding'} your friends list.`);
},
invalidatecache(target, room, user) {
this.canUseConsole();
for (const curUser of Users.users.values()) {
void Chat.Friends.updateUserCache(curUser);
}
Rooms.global.notifyRooms(
['staff', 'development'],
`|c|${user.getIdentity()}|/log ${user.name} used /friends invalidatecache`,
);
this.sendReply(`You invalidated each entry in the friends database cache.`);
},
sharebattles(target, room, user) {
Friends.checkCanUse(this);
target = toID(target);
if (this.meansYes(target)) {
if (user.settings.displayBattlesToFriends) {
return this.errorReply(this.tr`You are already sharing your battles with friends.`);
}
user.settings.displayBattlesToFriends = true;
this.sendReply(`You are now allowing your friends to see your ongoing battles.`);
} else if (this.meansNo(target)) {
if (!user.settings.displayBattlesToFriends) {
return this.errorReply(this.tr`You are already not sharing your battles with friends.`);
}
user.settings.displayBattlesToFriends = false;
this.sendReply(`You are now hiding your ongoing battles from your friends.`);
} else {
if (!target) return this.parse('/help friends sharebattles');
return this.errorReply(`Invalid setting '${target}'. Provide 'on' or 'off'.`);
}
user.update();
this.refreshPage('friends-settings');
},
sharebattleshelp: [
`/friends sharebattles [on|off] - Allow or disallow your friends from seeing your ongoing battles.`,
],
},
friendshelp() {
this.runBroadcast();
if (this.broadcasting) {
return this.sendReplyBox([
`/friend list - View current friends.`,
`/friend add [name] OR /friend [name] - Send a friend request to [name], if you don't have them added.`,
`/friend remove [username] OR /unfriend [username] - Unfriend the user.`,
`More commands...`,
`/friend accept [username] - Accepts the friend request from [username], if it exists.`,
`/friend reject [username] - Rejects the friend request from [username], if it exists.`,
`/friend toggle [off/on] - Enable or disable receiving of friend requests.`,
`/friend hidenotifications OR hidenotifs - Opts out of receiving friend notifications.`,
`/friend viewnotifications OR viewnotifs - Opts into view friend notifications.`,
`/friend listdisplay [on/off] - Opts [in/out] of letting others view your friends list.`,
`/friend viewlist [user] - View the given [user]'s friend list, if they're allowing others to see.`,
`/friends sharebattles [on|off] - Allow or disallow your friends from seeing your ongoing battles.`,
].join(' '));
}
return this.parse('/join view-friends-help');
},
};
export const pages: Chat.PageTable = {
async friends(args, user) {
if (!user.named) return Rooms.RETRY_AFTER_LOGIN;
Friends.checkCanUse(this);
const type = args.shift();
let buf = '
';
switch (toID(type)) {
case 'outgoing': case 'sent':
this.title = `[Friends] Sent`;
buf += headerButtons('sent', user);
if (user.settings.blockFriendRequests) {
buf += `
${this.tr(`You are currently blocking friend requests`)}.
`;
}
const { sent } = await Chat.Friends.getRequests(user);
if (sent.size < 1) {
buf += `You have no outgoing friend requests pending. `;
buf += ` To add a friend, use /friend add [username].`;
buf += `
`;
return toLink(buf);
}
buf += `
You have ${Chat.count(sent.size, 'friend requests')} pending${sent.size === MAX_REQUESTS ? ` (maximum reached)` : ''}.
`;
for (const request of sent) {
buf += `
`;
buf += `${request}`;
buf += ` `;
buf += `
`;
}
break;
case 'received': case 'incoming':
this.title = `[Friends] Received`;
buf += headerButtons('received', user);
const { received } = await Chat.Friends.getRequests(user);
if (received.size < 1) {
buf += `You have no pending friend requests.`;
buf += ``;
return toLink(buf);
}
buf += `
You have ${received.size} pending friend requests.
`;
}
break;
case 'viewuser':
const target = toID(args.shift());
if (!target) return this.errorReply(`Specify a user.`);
if (target === user.id) {
return this.errorReply(`Use /friends list to view your own list.`);
}
const { public_list: isAllowing } = await Chat.Friends.getSettings(target);
if (!isAllowing) return this.errorReply(`${target}'s friends list is not public or they do not have one.`);
this.title = `[Friends List] ${target}`;
buf += await Friends.visualizePublicList(target);
break;
case 'help':
this.title = `[Friends] Help`;
buf += headerButtons('help', user);
buf += `
Help
`;
buf += `/friend OR /friends OR /friendslist:
`;
buf += [
`/friend list - View current friends.`,
`/friend add [name] OR /friend [name] - Send a friend request to [name], if you don't have them added.`,
`/friend remove [username] OR /unfriend [username] - Unfriend the user.`,
`/friend accept [username] - Accepts the friend request from [username], if it exists.`,
`/friend reject [username] - Rejects the friend request from [username], if it exists.`,
`/friend toggle [off/on] - Enable or disable receiving of friend requests.`,
`/friend hidenotifications OR hidenotifs - Opts out of receiving friend notifications.`,
`/friend viewnotifications OR viewnotifs - Opts into view friend notifications.`,
`/friend listdisplay [on/off] - Opts [in/out] of letting others view your friends list.`,
`/friend viewlist [user] - View the given [user]'s friend list, if they're allowing others to see.`,
`/friends sharebattles [on|off] - Allow or disallow your friends from seeing your ongoing battles.`,
].join('
`;
const toggleMessage = user.settings.displayBattlesToFriends ?
' disallow your friends from seeing your hidden battles' :
' allow your friends to see your hidden battles';
buf += `Use the settings page to ${toggleMessage} on this page. `;
buf += ` `;
if (!user.friends?.size) {
buf += `
You have no friends to spectate.
`;
break;
}
const friends = [];
for (const friendID of user.friends) {
const friend = Users.getExact(friendID);
if (!friend) continue;
friends.push(friend);
}
if (!friends.length) {
buf += `None of your friends are currently around to spectate.`;
break;
}
const battles: [User, string][] = [];
for (const friend of friends) {
const curBattles: [User, string][] = [...friend.inRooms]
.filter(id => {
const battle = Rooms.get(id)?.battle;
return (
battle?.playerTable[friend.id] &&
(!battle.roomid.endsWith('pw') || friend.settings.displayBattlesToFriends)
);
})
.map(id => [friend, id]);
if (!curBattles.length) continue;
battles.push(...curBattles);
}
Utils.sortBy(battles, ([, id]) => -Number(id.split('-')[2]));
if (!battles.length) {
buf += `None of your friends are currently in a battle.`;
} else {
buf += battles.map(([friend, battle]) => {
// we've already ensured the battle exists in the filter above
// (and .battle only exists if it's a GameRoom, so this cast is safe)
const room = Rooms.get(battle) as GameRoom & { battle: Rooms.RoomBattle };
const format = Dex.formats.get(room.battle.format).name;
const rated = room.battle.rated ? `(Rated: ${room.battle.rated})` : '';
const title = room.title.includes(friend.name) ?
room.title.replace(friend.name, `${friend.name}`) :
(room.title + ` (with ${friend.name})`);
return `[${format}]${rated} ${title}`;
}).join(' ');
}
break;
default:
this.title = `[Friends] All Friends`;
buf += headerButtons('all', user);
buf += await Friends.visualizeList(user.id);
}
buf += ``;
return toLink(buf);
},
};
export const handlers: Chat.Handlers = {
onBattleStart(user) {
return Friends.updateSpectatorLists(user);
},
onBattleLeave(user, room) {
return Friends.updateSpectatorLists(user);
},
onBattleEnd(battle, winner, players) {
for (const id of players) {
const user = Users.get(id);
if (!user) continue;
Friends.updateSpectatorLists(user);
}
},
onDisconnect(user) {
void Chat.Friends.writeLogin(user.id);
},
};
export const loginfilter: Chat.LoginFilter = user => {
if (!Config.usesqlitefriends || !Users.globalAuth.atLeast(user, Config.usesqlitefriends)) {
return;
}
// notify users of pending requests
void Friends.notifyPending(user);
// (quietly) notify their friends (that have opted in) that they are online
void Friends.notifyConnection(user);
// write login time
void Chat.Friends.writeLogin(user.id);
void Chat.Friends.updateUserCache(user);
};