111 lines
4.7 KiB
TypeScript
111 lines
4.7 KiB
TypeScript
|
/*
|
||
|
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||
|
|
||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
you may not use this file except in compliance with the License.
|
||
|
You may obtain a copy of the License at
|
||
|
|
||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
|
||
|
Unless required by applicable law or agreed to in writing, software
|
||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
|
See the License for the specific language governing permissions and
|
||
|
limitations under the License.
|
||
|
*/
|
||
|
|
||
|
import { groupBy, mapValues, maxBy, minBy, sumBy, takeRight } from "lodash";
|
||
|
import { MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix";
|
||
|
|
||
|
import { Member } from "./direct-messages";
|
||
|
import DMRoomMap from "./DMRoomMap";
|
||
|
import { compare } from "./strings";
|
||
|
|
||
|
export const compareMembers = (
|
||
|
activityScores: Record<string, IActivityScore>,
|
||
|
memberScores: Record<string, IMemberScore>,
|
||
|
) => (a: Member | RoomMember, b: Member | RoomMember): number => {
|
||
|
const aActivityScore = activityScores[a.userId]?.score ?? 0;
|
||
|
const aMemberScore = memberScores[a.userId]?.score ?? 0;
|
||
|
const aScore = aActivityScore + aMemberScore;
|
||
|
const aNumRooms = memberScores[a.userId]?.numRooms ?? 0;
|
||
|
|
||
|
const bActivityScore = activityScores[b.userId]?.score ?? 0;
|
||
|
const bMemberScore = memberScores[b.userId]?.score ?? 0;
|
||
|
const bScore = bActivityScore + bMemberScore;
|
||
|
const bNumRooms = memberScores[b.userId]?.numRooms ?? 0;
|
||
|
|
||
|
if (aScore === bScore) {
|
||
|
if (aNumRooms === bNumRooms) {
|
||
|
return compare(a.userId, b.userId);
|
||
|
}
|
||
|
|
||
|
return bNumRooms - aNumRooms;
|
||
|
}
|
||
|
return bScore - aScore;
|
||
|
};
|
||
|
|
||
|
function joinedRooms(cli: MatrixClient): Room[] {
|
||
|
return cli.getRooms()
|
||
|
.filter(r => r.getMyMembership() === 'join')
|
||
|
// Skip low priority rooms and DMs
|
||
|
.filter(r => !DMRoomMap.shared().getUserIdForRoomId(r.roomId))
|
||
|
.filter(r => !Object.keys(r.tags).includes("m.lowpriority"));
|
||
|
}
|
||
|
|
||
|
interface IActivityScore {
|
||
|
lastSpoke: number;
|
||
|
score: number;
|
||
|
}
|
||
|
|
||
|
// Score people based on who have sent messages recently, as a way to improve the quality of suggestions.
|
||
|
// We do this by checking every room to see who has sent a message in the last few hours, and giving them
|
||
|
// a score which correlates to the freshness of their message. In theory, this results in suggestions
|
||
|
// which are closer to "continue this conversation" rather than "this person exists".
|
||
|
export function buildActivityScores(cli: MatrixClient): { [key: string]: IActivityScore } {
|
||
|
const now = new Date().getTime();
|
||
|
const earliestAgeConsidered = now - (60 * 60 * 1000); // 1 hour ago
|
||
|
const maxMessagesConsidered = 50; // so we don't iterate over a huge amount of traffic
|
||
|
const events = joinedRooms(cli)
|
||
|
.flatMap(room => takeRight(room.getLiveTimeline().getEvents(), maxMessagesConsidered))
|
||
|
.filter(ev => ev.getTs() > earliestAgeConsidered);
|
||
|
const senderEvents = groupBy(events, ev => ev.getSender());
|
||
|
return mapValues(senderEvents, events => {
|
||
|
const lastEvent = maxBy(events, ev => ev.getTs());
|
||
|
const distanceFromNow = Math.abs(now - lastEvent.getTs()); // abs to account for slight future messages
|
||
|
const inverseTime = (now - earliestAgeConsidered) - distanceFromNow;
|
||
|
return {
|
||
|
lastSpoke: lastEvent.getTs(),
|
||
|
// Scores from being in a room give a 'good' score of about 1.0-1.5, so for our
|
||
|
// score we'll try and award at least 1.0 for making the list, with 4.0 being
|
||
|
// an approximate maximum for being selected.
|
||
|
score: Math.max(1, inverseTime / (15 * 60 * 1000)), // 15min segments to keep scores sane
|
||
|
};
|
||
|
});
|
||
|
}
|
||
|
|
||
|
interface IMemberScore {
|
||
|
member: RoomMember;
|
||
|
score: number;
|
||
|
numRooms: number;
|
||
|
}
|
||
|
|
||
|
export function buildMemberScores(cli: MatrixClient): { [key: string]: IMemberScore } {
|
||
|
const maxConsideredMembers = 200;
|
||
|
const consideredRooms = joinedRooms(cli).filter(room => room.getJoinedMemberCount() < maxConsideredMembers);
|
||
|
const memberPeerEntries = consideredRooms
|
||
|
.flatMap(room =>
|
||
|
room.getJoinedMembers().map(member =>
|
||
|
({ member, roomSize: room.getJoinedMemberCount() })));
|
||
|
const userMeta = groupBy(memberPeerEntries, ({ member }) => member.userId);
|
||
|
return mapValues(userMeta, roomMemberships => {
|
||
|
const maximumPeers = maxConsideredMembers * roomMemberships.length;
|
||
|
const totalPeers = sumBy(roomMemberships, entry => entry.roomSize);
|
||
|
return {
|
||
|
member: minBy(roomMemberships, entry => entry.roomSize).member,
|
||
|
numRooms: roomMemberships.length,
|
||
|
score: Math.max(0, Math.pow(1 - (totalPeers / maximumPeers), 5)),
|
||
|
};
|
||
|
});
|
||
|
}
|