issueManagerStore.ts 14.3 Ko
Télécharger le fichier
import { IssueMessageData } from '@octane/renderer';
import { createOctaneStore } from '../../state/createOctaneStore';
export const ISSUE_STATE_OPEN = 1;
export const ISSUE_STATE_PICKED = 2;
export const ISSUE_STATE_CLOSED = 3;
/** An issue as the moderator tool holds it: the message plus the time it arrived (the classic client's `getTimer()` stamp). */
export interface TrackedIssue {
issue: IssueMessageData;
receivedAt: number;
}
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
/** Classic `qc._ra5299c27b0a858`: how long one issue has been open, "hh:mm". */
export const issueOpenTime = (tracked: TrackedIssue, clock = now()): string => {
const minutes = Math.floor(Math.floor((tracked.issue.issueAgeInMilliseconds + clock - tracked.receivedAt) / 1000) / 60);
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return `${hours < 10 ? '0' : ''}${hours}:${rest < 10 ? '0' : ''}${rest}`;
};
/** Classic `ame` (issue bundle): issues about the same subject (grouping id and reported user) in the same state and picker are shown as one row. */
export class IssueBundle {
public readonly issues = new Map<number, TrackedIssue>();
public state: number;
public pickerUserId: number;
public pickerName: string;
public readonly reportedUserId: number;
public readonly groupingId: number;
private _messageCount = 0;
private _newest: TrackedIssue = null;
private _primary: TrackedIssue = null;
constructor(public readonly id: number, first: TrackedIssue) {
this.state = first.issue.state;
this.pickerUserId = first.issue.pickerUserId;
this.pickerName = first.issue.pickerUserName ?? '';
this.reportedUserId = first.issue.reportedUserId;
this.groupingId = first.issue.groupingId;
this.add(first);
}
public matches(issue: IssueMessageData, ignoreStatePicker = false): boolean {
return !(
this.groupingId === 0 ||
issue.groupingId === 0 ||
this.groupingId !== issue.groupingId ||
this.reportedUserId !== issue.reportedUserId ||
(!ignoreStatePicker && (this.state !== issue.state || this.pickerUserId !== issue.pickerUserId))
);
}
public contains(issueId: number): boolean {
return this.issues.has(issueId);
}
public replace(tracked: TrackedIssue): void {
this.remove(tracked.issue.issueId);
this.add(tracked);
}
public remove(issueId: number): TrackedIssue {
const removed = this.issues.get(issueId) ?? null;
this.issues.delete(issueId);
if (removed) {
if ((removed.issue.message ?? '') !== '') this._messageCount--;
if (this._newest === removed) this._newest = null;
if (this._primary === removed) this._primary = null;
}
return removed;
}
private add(tracked: TrackedIssue): void {
this.issues.set(tracked.issue.issueId, tracked);
if ((tracked.issue.message ?? '') !== '') this._messageCount++;
if (this._newest == null || tracked.issue.issueAgeInMilliseconds > this._newest.issue.issueAgeInMilliseconds) this._newest = tracked;
this._primary = null;
}
public get issueIds(): number[] {
return [...this.issues.keys()];
}
public get count(): number {
return this.issues.size;
}
public get messageCount(): number {
return this._messageCount;
}
/** The age of the newest issue (the sort key after the priority). */
public get age(): number {
return this._newest?.issue.issueAgeInMilliseconds ?? 0;
}
/** `_r6652783bf1648b`: of the issues, the lowest priority one whose reported category is in 1..99, else the lowest priority one of the rest. */
public get primary(): TrackedIssue {
if (this._primary) return this._primary;
if (this.issues.size < 1) return null;
let preferred: TrackedIssue = null;
let other: TrackedIssue = null;
for (const tracked of this.issues.values()) {
const { reportedCategoryId, priority } = tracked.issue;
if (reportedCategoryId > 0 && reportedCategoryId < 100) {
if (preferred == null || preferred.issue.priority > priority) preferred = tracked;
} else if (other == null || other.issue.priority > priority) other = tracked;
}
this._primary = preferred ?? other;
return this._primary;
}
public get priority(): number {
return this.primary?.issue.priority ?? 0;
}
/** `_ra5299c27b0a858`: how long the newest issue has been open as "hh:mm": its age plus the time since it arrived. */
public openTime(clock = now()): string {
let newest: TrackedIssue = null;
for (const tracked of this.issues.values()) {
if (newest == null || tracked.issue.issueAgeInMilliseconds > newest.issue.issueAgeInMilliseconds) newest = tracked;
}
if (!newest) return '';
const minutes = Math.floor(Math.floor((newest.issue.issueAgeInMilliseconds + clock - newest.receivedAt) / 1000) / 60);
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return `${hours < 10 ? '0' : ''}${hours}:${rest < 10 ? '0' : ''}${rest}`;
}
}
interface IssueManagerState {
issues: Map<number, TrackedIssue>;
bundles: Map<number, IssueBundle>;
issueBundle: Map<number, number>;
nextBundleId: number;
/** Issues the moderator asked to pick that have not been answered yet (`_r6d54f7f16cbe62`): their answer opens the handler. */
pendingPick: number[];
/** Issues the moderator asked to release (`_r65a92c25921a0c`). */
pendingRelease: number[];
/** The saved position of the issue handler window (ModeratorToolPreferences): the handler opens at x, y (0, 0 until the server says otherwise). */
preferences: { x: number; y: number; width: number; height: number };
/** Bumped on every change: the views re-read the manager. */
version: number;
}
export const useIssueManagerStore = createOctaneStore<IssueManagerState>()(() => ({
issues: new Map(),
bundles: new Map(),
issueBundle: new Map(),
nextBundleId: 1,
pendingPick: [],
pendingRelease: [],
preferences: { x: 0, y: 0, width: 0, height: 0 },
version: 0
}));
const touch = () => useIssueManagerStore.setState((state) => ({ version: state.version + 1 }));
export interface IssueManagerContext {
userId: number;
/** Sends a pick for the issues (`EF`): the retry flag, the retry count and the reason of the call. */
pick: (issueIds: number[], retry: boolean, retryCount: number, reason: string) => void;
/** Sends a release for the issues (`MF`). */
release: (issueIds: number[]) => void;
/** Opens the handler for a bundle (`_rf0cee18d10867c`). */
openHandler: (bundleId: number) => void;
/** Disposes the handler of a bundle (`_r242784630dd192`). */
closeHandler: (bundleId: number) => void;
/** The handler of a bundle shows its issues again (`_r8ce51cc736cea8`). */
refreshHandler: (bundleId: number) => void;
/** A new issue arrived while the browser is closed (the new ticket sound). */
notifyNewIssue: () => void;
isBrowserOpen: () => boolean;
}
/** `_r2bdf209d0049c2`: the bundle an issue already belongs to, kept when it still matches and left (deleting an emptied bundle) when it does not. */
const currentBundle = (issue: IssueMessageData): IssueBundle => {
const state = useIssueManagerStore.getState();
const bundleId = state.issueBundle.get(issue.issueId) ?? 0;
if (bundleId === 0) return null;
const bundle = state.bundles.get(bundleId) ?? null;
if (bundle == null) return null;
if (bundle.matches(issue)) {
bundle.replace({ issue, receivedAt: now() });
return bundle;
}
bundle.remove(issue.issueId);
if (bundle.count === 0) state.bundles.delete(bundle.id);
state.issueBundle.delete(issue.issueId);
return null;
};
/** Classic GI `_rf669d43291f75e`: an issue arrived or changed. */
export const onIssueInfo = (issue: IssueMessageData, context: IssueManagerContext): void => {
const state = useIssueManagerStore.getState();
const isNew = !state.issues.has(issue.issueId);
// `_r82a619a151b36d`: the first sight of an issue sounds the new ticket alert unless the browser is open
if (isNew && !context.isBrowserOpen()) context.notifyNewIssue();
const tracked: TrackedIssue = { issue, receivedAt: now() };
state.issues.delete(issue.issueId);
state.issues.set(issue.issueId, tracked);
let bundle = currentBundle(issue);
if (issue.state === ISSUE_STATE_CLOSED) {
state.issues.delete(issue.issueId);
touch();
return;
}
if (bundle == null) {
for (const candidate of state.bundles.values()) {
if (candidate.matches(issue)) {
bundle = candidate;
bundle.replace(tracked);
state.issueBundle.set(issue.issueId, bundle.id);
break;
}
}
}
if (bundle == null) {
const id = state.nextBundleId;
useIssueManagerStore.setState({ nextBundleId: id + 1 });
bundle = new IssueBundle(id, tracked);
state.issueBundle.set(issue.issueId, id);
state.bundles.set(id, bundle);
}
// an issue the moderator picked is answered with its picked state: its handler opens (or goes away when another moderator got it)
if (state.pendingPick.includes(issue.issueId)) {
context.openHandler(bundle.id);
if (context.userId !== issue.pickerUserId && issue.state === ISSUE_STATE_PICKED) context.closeHandler(bundle.id);
}
// an open issue that belongs to a bundle the moderator holds is picked along with it, unless it was released on purpose
if (issue.state === ISSUE_STATE_OPEN) {
const mine = bundlesFor('my', context.userId);
const holder = mine.find((candidate) => candidate.matches(issue, true)) ?? null;
const releasedAt = state.pendingRelease.indexOf(issue.issueId);
if (releasedAt === -1 && holder != null) {
pickIssues([issue.issueId], false, 0, `matches bundle with issue: ${holder.primary?.issue.issueId ?? 0}`, context);
} else if (releasedAt >= 0) {
state.pendingRelease.splice(releasedAt, 1);
}
}
context.refreshHandler(bundle.id);
touch();
};
/** Classic GI `_r60fabbb8ef52a0`: an issue is gone. */
export const onIssueDeleted = (issueId: number): void => {
const state = useIssueManagerStore.getState();
const bundleId = state.issueBundle.get(issueId) ?? 0;
if (bundleId !== 0) {
const bundle = state.bundles.get(bundleId) ?? null;
if (bundle != null) {
bundle.remove(issueId);
if (bundle.count === 0) state.bundles.delete(bundle.id);
}
}
state.issues.delete(issueId);
touch();
};
export type IssueTab = 'open' | 'my' | 'picked';
/** Classic GI `_rde59c91bfa9c62`: the bundles of a browser tab. */
export const bundlesFor = (tab: IssueTab, userId: number): IssueBundle[] => {
const result: IssueBundle[] = [];
for (const bundle of useIssueManagerStore.getState().bundles.values()) {
if (tab === 'open' && bundle.state === ISSUE_STATE_OPEN) result.push(bundle);
else if (tab === 'my' && bundle.state === ISSUE_STATE_PICKED && bundle.pickerUserId === userId) result.push(bundle);
else if (tab === 'picked' && bundle.state === ISSUE_STATE_PICKED && bundle.pickerUserId !== userId) result.push(bundle);
}
return result;
};
/** The order of a browser list (`dg.update`): lowest priority first, then the lowest age. */
export const sortBundles = (bundles: IssueBundle[]): IssueBundle[] => [...bundles].sort((a, b) => (a.priority !== b.priority ? a.priority - b.priority : a.age - b.age));
/** Classic GI `_rb7f9385ecb510f`: asks for issues to be picked. */
export const pickIssues = (issueIds: number[], retry: boolean, retryCount: number, reason: string, context: IssueManagerContext): void => {
if (issueIds.length === 0) return;
context.pick(issueIds, retry, retryCount, reason);
};
/** Classic GI `_r9dc4e0be4fb276`: picks a bundle's issues and remembers them so their answer opens the handler. */
export const pickBundle = (bundleId: number, reason: string, context: IssueManagerContext, retry = false, retryCount = 0): void => {
const state = useIssueManagerStore.getState();
const bundle = state.bundles.get(bundleId) ?? null;
if (bundle == null) return;
pickIssues(bundle.issueIds, retry, retryCount, reason, context);
useIssueManagerStore.setState({ pendingPick: state.pendingPick.concat(bundle.issueIds) });
};
/** Classic GI `_r331af738c24783`'s choice: the open bundle with the lowest priority (the lowest age among equals). */
export const nextOpenBundle = (): IssueBundle | null => {
let best: IssueBundle = null;
for (const bundle of useIssueManagerStore.getState().bundles.values()) {
if (bundle.state !== ISSUE_STATE_OPEN) continue;
if (best == null || bundle.priority < best.priority || (bundle.priority === best.priority && bundle.age < best.age)) best = bundle;
}
return best;
};
/** Classic GI `_r331af738c24783`: picks the best open bundle. */
export const pickNext = (reason: string, context: IssueManagerContext, retry = false, retryCount = 0): void => {
const best = nextOpenBundle();
if (best != null) pickBundle(best.id, reason, context, retry, retryCount);
};
/** Classic GI `_r9f403a3db5542f`: releases issues. */
export const releaseIssues = (issueIds: number[], context: IssueManagerContext): void => {
if (issueIds.length === 0) return;
context.release(issueIds);
useIssueManagerStore.setState((state) => ({ pendingRelease: state.pendingRelease.concat(issueIds) }));
};
export const releaseBundle = (bundleId: number, context: IssueManagerContext): void => {
const bundle = useIssueManagerStore.getState().bundles.get(bundleId) ?? null;
if (bundle != null) releaseIssues(bundle.issueIds, context);
};
/** Classic GI `_r05d8d2c4594132`: releases everything the moderator holds. */
export const releaseAll = (context: IssueManagerContext): void => {
let ids: number[] = [];
for (const bundle of bundlesFor('my', context.userId)) ids = ids.concat(bundle.issueIds);
releaseIssues(ids, context);
};
