Rejoin: same exact instance before, with time ago

This commit is contained in:
Claude committed 2026-09-27 02:38:26 +02:00
1 parent 1afc9bad95
commit ad608daf8b
7 files changed
+76 -148

No files matched your search

+3 -4
View File
@@ -8,7 +8,7 @@ Player "Tupper" joined
Avatar PC Performance Rank: Good Avatar PC Performance Rank: Good
Avatar Quest Performance Rank: Poor Avatar Quest Performance Rank: Poor
In Group: Yes In Group: Yes
Rejoin?: Yes (met 2× before; last 26/09/2026, 21:40:12 in Lovers Wedding; 1 of the last 3 in matching instances) Rejoin?: Yes (2 hours ago)
``` ```
`In Group` only appears when a group filter is set. `In Group` only appears when a group filter is set.
@@ -39,7 +39,7 @@ All optional, all in the plugin's settings card. Empty means "any".
| 18+ verified | VRCNext fetches the joiner's profile itself on join and re-pushes the instance; the plugin waits for that push. | Legacy accounts without a `usr_` id cannot be looked up. | | 18+ verified | VRCNext fetches the joiner's profile itself on join and re-pushes the instance; the plugin waits for that push. | Legacy accounts without a `usr_` id cannot be looked up. |
| Avatar ranks | `vrcGetInstanceAvatars` resolves the avatar from the profile image through VRCNext's avatar databases, then `vrcGetAvatarDetail` gives the ranks. | Only works when one of the databases knows the avatar. The detail action also feeds VRCNext's avatar modal, so it is skipped while that modal is open. | | Avatar ranks | `vrcGetInstanceAvatars` resolves the avatar from the profile image through VRCNext's avatar databases, then `vrcGetAvatarDetail` gives the ranks. | Only works when one of the databases knows the avatar. The detail action also feeds VRCNext's avatar modal, so it is skipped while that modal is open. |
| In group | `vrcGetGroupsForNetwork`, the joiner's publicly visible groups. | A member who hides the membership shows as "No". | | In group | `vrcGetGroupsForNetwork`, the joiner's publicly visible groups. | A member who hides the membership shows as "No". |
| Rejoin | VRCNext's own timeline: its `first_meet` / `meet_again` event for this join (with its meeting count) and `getTimelineForUser`, the player's ten most recent timeline events with where and when. | Survives VRCNext and VRChat restarts and reaches back to when VRCNext was installed. The "in matching instances" count only sees those ten events, and says so. | | Rejoin | `getTimelineForUser`: the player's ten most recent timeline events, each with its location. Yes when one of them is this exact instance (same world and instance id) from before this join, with how long ago. | Survives VRCNext and VRChat restarts and reaches back to when VRCNext was installed, but only ten events deep per player. |
Everything waits at most the configured number of seconds, then reports what it has, with Everything waits at most the configured number of seconds, then reports what it has, with
`Unknown` for the rest. `Unknown` for the rest.
@@ -49,8 +49,7 @@ Everything waits at most the configured number of seconds, then reports what it
The signed-in account comes from the `vrcUser` event VRCNext pushes after login; the plugin The signed-in account comes from the `vrcUser` event VRCNext pushes after login; the plugin
reads no page globals. VRChat logs an `OnPlayerJoined` line for the local player too, and right after it one line for reads no page globals. VRChat logs an `OnPlayerJoined` line for the local player too, and right after it one line for
every player already in the instance. The plugin ignores your own line and treats joins inside every player already in the instance. The plugin ignores your own line and treats joins inside
the settle window after it (or after a world change) as "already here": not reported, and nothing the settle window after it (or after a world change) as "already here" and does not report them.
is lost because VRCNext records the meeting itself.
## Layout and permissions ## Layout and permissions
+3 -6
View File
@@ -12,7 +12,7 @@
import { definePlugin, type PluginContext, type PluginId } from '@vrcnext/plugin-api'; import { definePlugin, type PluginContext, type PluginId } from '@vrcnext/plugin-api';
import { FactCollector, type Joiner } from './src/collector.js'; import { FactCollector, type Joiner } from './src/collector.js';
import { filterFrom, instanceMatches, type InstanceFilter } from './src/filters.js'; import { filterFrom, instanceMatches } from './src/filters.js';
import { notifyAll, type Report } from './src/notify.js'; import { notifyAll, type Report } from './src/notify.js';
import { ReportPanel } from './src/panel.js'; import { ReportPanel } from './src/panel.js';
import { settings } from './src/settings.js'; import { settings } from './src/settings.js';
@@ -24,9 +24,6 @@ type Ctx = PluginContext<typeof settings>;
const JOIN_EVENT = 'gl_player_join'; const JOIN_EVENT = 'gl_player_join';
const WORLD_JOIN_EVENT = 'gl_world_join'; const WORLD_JOIN_EVENT = 'gl_world_join';
function isFiltered(filter: InstanceFilter): boolean {
return filter.instanceTypes.length > 0 || filter.groupId !== '' || filter.worldIds.length > 0;
}
class ClubSecurity { class ClubSecurity {
readonly #ctx: Ctx; readonly #ctx: Ctx;
@@ -100,7 +97,7 @@ class ClubSecurity {
return; return;
} }
const facts = await this.#collector.collect(joiner, filter, values.collectTimeoutSecs * 1000); const facts = await this.#collector.collect(joiner, filter, values.collectTimeoutSecs * 1000);
const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId, filtered: isFiltered(filter) }; const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId };
this.#panel.push(report); this.#panel.push(report);
this.#ctx.logger.info(`Reported ${joiner.name}: 18+ ${String(facts.ageVerified ?? '?')}, PC ${facts.avatar?.pc ?? '?'}, Quest ${facts.avatar?.quest ?? '?'}.`); this.#ctx.logger.info(`Reported ${joiner.name}: 18+ ${String(facts.ageVerified ?? '?')}, PC ${facts.avatar?.pc ?? '?'}, Quest ${facts.avatar?.quest ?? '?'}.`);
await notifyAll(this.#ctx, report); await notifyAll(this.#ctx, report);
@@ -130,7 +127,7 @@ class ClubSecurity {
const joiner: Joiner = { name: name === '' ? 'Test player' : name, userId: me?.id ?? '' }; const joiner: Joiner = { name: name === '' ? 'Test player' : name, userId: me?.id ?? '' };
const filter = filterFrom(this.#ctx.settings.values); const filter = filterFrom(this.#ctx.settings.values);
const facts = await this.#collector.collect(joiner, filter, 10_000); const facts = await this.#collector.collect(joiner, filter, 10_000);
const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId, filtered: isFiltered(filter) }; const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId };
await notifyAll(this.#ctx, report); await notifyAll(this.#ctx, report);
this.#ctx.notifications.toast({ message: 'Test report sent to every enabled channel.' }); this.#ctx.notifications.toast({ message: 'Test report sent to every enabled channel.' });
} }
+1 -2
View File
@@ -3,7 +3,7 @@
"name": "Club Security", "name": "Club Security",
"version": "1.0.0", "version": "1.0.0",
"apiVersion": "^0.2.0", "apiVersion": "^0.2.0",
"description": "When someone joins your instance, gathers age verification, avatar performance, group membership and VRCNext's meeting history, then notifies you in-app, on the desktop, in VR or on Discord.", "description": "When someone joins your instance, gathers age verification, avatar performance, group membership and whether they were in this instance with you before, then notifies you in-app, on the desktop, in VR or on Discord.",
"author": "vrcnext-plugins", "author": "vrcnext-plugins",
"homepage": "https://github.com/vrcnext-plugins/vrcnext-plugin-system/tree/main/examples/club-security", "homepage": "https://github.com/vrcnext-plugins/vrcnext-plugin-system/tree/main/examples/club-security",
"tags": [ "tags": [
@@ -28,7 +28,6 @@
"vrcGetInstanceAvatars" "vrcGetInstanceAvatars"
], ],
"events": [ "events": [
"timelineEvent",
"timelineForUser", "timelineForUser",
"vrcAvatarDetail", "vrcAvatarDetail",
"vrcCurrentInstance", "vrcCurrentInstance",
+13 -31
View File
@@ -16,7 +16,7 @@
import type { EventBus, PluginContext } from '@vrcnext/plugin-api'; import type { EventBus, PluginContext } from '@vrcnext/plugin-api';
import type { InstanceFilter } from './filters.js'; import type { InstanceFilter } from './filters.js';
import { summarise, toMeetEvent, toTimelineEntries, type MeetEvent, type MeetHistory } from './history.js'; import { rejoinIn, toTimelineEntries, UNKNOWN_REJOIN, type Rejoin } from './history.js';
import type { Settings } from './settings.js'; import type { Settings } from './settings.js';
import { import {
profileLoaded, profileLoaded,
@@ -47,7 +47,7 @@ export interface Facts {
readonly avatar: AvatarFacts | undefined; readonly avatar: AvatarFacts | undefined;
/** `undefined` when no group filter is configured. */ /** `undefined` when no group filter is configured. */
readonly inGroup: boolean | undefined; readonly inGroup: boolean | undefined;
readonly history: MeetHistory; readonly rejoin: Rejoin;
} }
type Ctx = PluginContext<Settings>; type Ctx = PluginContext<Settings>;
@@ -95,8 +95,6 @@ export class FactCollector {
#latest: CurrentInstance | undefined; #latest: CurrentInstance | undefined;
/** Performance ranks by avatar id, so a popular avatar is only fetched once. */ /** Performance ranks by avatar id, so a popular avatar is only fetched once. */
readonly #perfCache = new Map<string, AvatarFacts>(); readonly #perfCache = new Map<string, AvatarFacts>();
/** The latest first_meet / meet_again VRCNext pushed per user; it can arrive before the join line. */
readonly #meets = new Map<string, MeetEvent>();
constructor(ctx: Ctx) { constructor(ctx: Ctx) {
this.#ctx = ctx; this.#ctx = ctx;
@@ -104,10 +102,6 @@ export class FactCollector {
const instance = toCurrentInstance(payload); const instance = toCurrentInstance(payload);
if (instance !== undefined) this.#latest = instance; if (instance !== undefined) this.#latest = instance;
}); });
ctx.events.on('timelineEvent', (payload) => {
const meet = toMeetEvent(payload);
if (meet !== undefined) this.#meets.set(meet.userId, meet);
});
ctx.events.on('vrcAvatarDetail', (payload) => { ctx.events.on('vrcAvatarDetail', (payload) => {
const perf = toAvatarPerformance(payload); const perf = toAvatarPerformance(payload);
if (perf !== undefined) { if (perf !== undefined) {
@@ -124,11 +118,11 @@ export class FactCollector {
async collect(joiner: Joiner, filter: InstanceFilter, deadlineMs: number): Promise<Facts> { async collect(joiner: Joiner, filter: InstanceFilter, deadlineMs: number): Promise<Facts> {
const signal = this.#ctx.signal; const signal = this.#ctx.signal;
const joinedAt = Date.now(); const joinedAt = Date.now();
const [profile, inGroup, avatar, history] = await Promise.all([ const [profile, inGroup, avatar, rejoin] = await Promise.all([
this.#profile(joiner, deadlineMs, signal), this.#profile(joiner, deadlineMs, signal),
filter.groupId === '' ? Promise.resolve(undefined) : this.#membership(joiner, filter.groupId, deadlineMs, signal), filter.groupId === '' ? Promise.resolve(undefined) : this.#membership(joiner, filter.groupId, deadlineMs, signal),
this.#avatar(joiner, deadlineMs, signal), this.#avatar(joiner, deadlineMs, signal),
this.#history(joiner, filter, joinedAt, { ms: deadlineMs, signal }), this.#rejoin(joiner, joinedAt, { ms: deadlineMs, signal }),
]); ]);
return { return {
ageVerified: profile === undefined ? undefined : profile.ageVerified, ageVerified: profile === undefined ? undefined : profile.ageVerified,
@@ -136,37 +130,25 @@ export class FactCollector {
platform: profile?.platform ?? '', platform: profile?.platform ?? '',
avatar, avatar,
inGroup, inGroup,
history, rejoin,
}; };
} }
/** /**
* VRCNext's own record of earlier meetings: the live meet event for this join, plus the user's * Whether VRCNext's timeline already has this player in this exact instance. Legacy accounts
* recent timeline. Legacy accounts without a `usr_` id have neither. * without a `usr_` id cannot be looked up.
*/ */
async #history( async #rejoin(joiner: Joiner, joinedAt: number, window: { readonly ms: number; readonly signal: AbortSignal }): Promise<Rejoin> {
joiner: Joiner, const location = this.#latest?.location ?? '';
filter: InstanceFilter, if (joiner.userId === '' || location === '') return UNKNOWN_REJOIN;
joinedAt: number, const pending = waitFor(
window: { readonly ms: number; readonly signal: AbortSignal },
): Promise<MeetHistory> {
const { ms, signal } = window;
if (joiner.userId === '') return summarise(undefined, undefined, filter, joinedAt);
const meetPending = this.#meets.get(joiner.userId) !== undefined
? Promise.resolve(this.#meets.get(joiner.userId))
: waitFor(this.#ctx.events, 'timelineEvent', (payload) => {
const meet = toMeetEvent(payload);
return meet?.userId === joiner.userId ? meet : undefined;
}, { ms: Math.min(ms, 8_000), signal });
const timelinePending = waitFor(
this.#ctx.events, this.#ctx.events,
'timelineForUser', 'timelineForUser',
(payload) => toTimelineEntries(payload, joiner.userId), (payload) => toTimelineEntries(payload, joiner.userId),
{ ms, signal }, window,
); );
this.#ctx.bridge.send('getTimelineForUser', { userId: joiner.userId }); this.#ctx.bridge.send('getTimelineForUser', { userId: joiner.userId });
const [meet, entries] = await Promise.all([meetPending, timelinePending]); return rejoinIn(await pending, location, joinedAt);
return summarise(meet, entries, filter, joinedAt);
} }
async #profile(joiner: Joiner, ms: number, signal: AbortSignal): Promise<InstanceUser | undefined> { async #profile(joiner: Joiner, ms: number, signal: AbortSignal): Promise<InstanceUser | undefined> {
+46 -80
View File
@@ -1,51 +1,25 @@
/** /**
* Whether a joiner has been met before, from VRCNext's own timeline. * Whether a joiner has been in this exact instance with you before, from VRCNext's own timeline.
* *
* VRCNext keeps a `known_users` table and, on every join, records a `first_meet` or `meet_again` * `getTimelineForUser` returns the ten most recent timeline events involving the player, each with
* timeline event for that player (pushed live as `timelineEvent`, with `meetCount` on repeats). * a timestamp and the location it happened in. VRCNext keeps those in SQLite, so they survive
* `getTimelineForUser` returns the ten most recent timeline events involving the player, each * VRCNext and VRChat restarts and reach back to when VRCNext was installed. The check is strict on
* with a timestamp and the location it happened in. Both survive VRCNext and VRChat restarts and * purpose: same world and same instance id, not "met somewhere" and not "an instance like this".
* reach back to when VRCNext was installed, which no memory of the plugin's own could.
*
* The ten-event window is the honest limit: "how many times in a matching instance" counts only
* what is in it, and the report says so.
*/ */
import { instanceMatches, shapeOfLocation, type InstanceFilter } from './filters.js';
export interface MeetEvent {
readonly type: 'first_meet' | 'meet_again';
readonly userId: string;
readonly timestamp: string;
readonly meetCount: number;
}
export interface TimelineEntry { export interface TimelineEntry {
readonly type: string;
readonly timestamp: string; readonly timestamp: string;
readonly worldName: string;
readonly location: string; readonly location: string;
} }
export interface MeetHistory { export interface Rejoin {
/** `undefined` when VRCNext said nothing in time. */ /** `undefined` when VRCNext did not answer in time. */
readonly metBefore: boolean | undefined; readonly seenHere: boolean | undefined;
/** VRCNext's count of earlier meetings, when it reported one. */ /** When the earlier visit was recorded, as VRCNext's timestamp. */
readonly meetCount: number; readonly lastAt: string | undefined;
readonly lastMet: { readonly at: string; readonly worldName: string } | undefined;
/** Earlier events, within the window VRCNext returned, that happened in a matching instance. */
readonly matchingBefore: number | undefined;
/** How many earlier events the window held, so the report can say how far back it looked. */
readonly windowSize: number;
} }
export const UNKNOWN_HISTORY: MeetHistory = { export const UNKNOWN_REJOIN: Rejoin = { seenHere: undefined, lastAt: undefined };
metBefore: undefined,
meetCount: 0,
lastMet: undefined,
matchingBefore: undefined,
windowSize: 0,
};
function rec(value: unknown): Record<string, unknown> | undefined { function rec(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined; return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined;
@@ -55,17 +29,6 @@ function str(value: unknown): string {
return typeof value === 'string' ? value : ''; return typeof value === 'string' ? value : '';
} }
/** Narrows a `timelineEvent` push to the two meeting kinds; anything else is `undefined`. */
export function toMeetEvent(payload: unknown): MeetEvent | undefined {
const r = rec(payload);
if (r === undefined) return undefined;
const type = str(r['type']);
const userId = str(r['userId']);
if ((type !== 'first_meet' && type !== 'meet_again') || userId === '') return undefined;
const count = r['meetCount'];
return { type, userId, timestamp: str(r['timestamp']), meetCount: typeof count === 'number' ? count : 0 };
}
/** Narrows a `timelineForUser` reply for one user. */ /** Narrows a `timelineForUser` reply for one user. */
export function toTimelineEntries(payload: unknown, userId: string): readonly TimelineEntry[] | undefined { export function toTimelineEntries(payload: unknown, userId: string): readonly TimelineEntry[] | undefined {
const r = rec(payload); const r = rec(payload);
@@ -73,44 +36,47 @@ export function toTimelineEntries(payload: unknown, userId: string): readonly Ti
const events = Array.isArray(r['events']) ? r['events'] : []; const events = Array.isArray(r['events']) ? r['events'] : [];
return events.flatMap((e) => { return events.flatMap((e) => {
const entry = rec(e); const entry = rec(e);
if (entry === undefined) return []; return entry === undefined ? [] : [{ timestamp: str(entry['timestamp']), location: str(entry['location']) }];
return [{
type: str(entry['type']),
timestamp: str(entry['timestamp']),
worldName: str(entry['worldName']),
location: str(entry['location']),
}];
}); });
} }
/** `wrld_…:12345` — the part of a location that identifies the instance, without its modifiers. */
export function instanceKey(location: string): string {
return location.split('~')[0] ?? '';
}
/** /**
* Combines the live meet event with the user's timeline into one answer. * Looks for an earlier event in the same instance. Events from this join are excluded by
* * timestamp: anything at or after `joinedAt` describes this visit, not an earlier one.
* Events from the current join are excluded by timestamp: anything at or after `joinedAt`
* describes this visit, not an earlier one.
*/ */
export function summarise( export function rejoinIn(
meet: MeetEvent | undefined,
entries: readonly TimelineEntry[] | undefined, entries: readonly TimelineEntry[] | undefined,
filter: InstanceFilter, location: string,
joinedAt: number, joinedAt: number,
): MeetHistory { ): Rejoin {
const earlier = (entries ?? []).filter((e) => { if (entries === undefined) return UNKNOWN_REJOIN;
const at = Date.parse(e.timestamp); const key = instanceKey(location);
return Number.isFinite(at) && at < joinedAt - 5_000; if (key === '') return { seenHere: false, lastAt: undefined };
}); const earlier = entries
earlier.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); .filter((e) => instanceKey(e.location) === key)
.filter((e) => {
const metBefore = meet !== undefined ? meet.type === 'meet_again' : (entries === undefined ? undefined : earlier.length > 0); const at = Date.parse(e.timestamp);
return Number.isFinite(at) && at < joinedAt - 5_000;
})
.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
const last = earlier[0]; const last = earlier[0];
const matching = entries === undefined return { seenHere: last !== undefined, lastAt: last?.timestamp };
? undefined }
: earlier.filter((e) => e.location !== '' && instanceMatches(filter, shapeOfLocation(e.location))).length;
return { /** `3 minutes ago`, `2 hours ago`, `5 days ago`. */
metBefore, export function timeAgo(iso: string, now = Date.now()): string {
meetCount: meet?.meetCount ?? 0, const ms = now - Date.parse(iso);
lastMet: last === undefined ? undefined : { at: last.timestamp, worldName: last.worldName }, if (!Number.isFinite(ms) || ms < 0) return 'just now';
matchingBefore: matching, const minutes = Math.floor(ms / 60_000);
windowSize: earlier.length, if (minutes < 1) return 'just now';
}; if (minutes < 60) return `${String(minutes)} minute${minutes === 1 ? '' : 's'} ago`;
const hours = Math.floor(minutes / 60);
if (hours < 48) return `${String(hours)} hour${hours === 1 ? '' : 's'} ago`;
const days = Math.floor(hours / 24);
return `${String(days)} day${days === 1 ? '' : 's'} ago`;
} }
+8 -23
View File
@@ -13,7 +13,7 @@
import type { PluginContext, SettingsValues } from '@vrcnext/plugin-api'; import type { PluginContext, SettingsValues } from '@vrcnext/plugin-api';
import type { Facts, Joiner } from './collector.js'; import type { Facts, Joiner } from './collector.js';
import type { MeetHistory } from './history.js'; import { timeAgo, type Rejoin } from './history.js';
import type { Settings } from './settings.js'; import type { Settings } from './settings.js';
import type { CurrentInstance } from './vrcnext-data.js'; import type { CurrentInstance } from './vrcnext-data.js';
@@ -24,8 +24,6 @@ export interface Report {
readonly facts: Facts; readonly facts: Facts;
/** Whether a group filter was active, so the "In Group" line is only shown when it means something. */ /** Whether a group filter was active, so the "In Group" line is only shown when it means something. */
readonly groupFilter: string; readonly groupFilter: string;
/** Whether any filter was active, so the matching-instance count is only shown when it means something. */
readonly filtered: boolean;
} }
type Ctx = PluginContext<Settings>; type Ctx = PluginContext<Settings>;
@@ -42,24 +40,11 @@ function rank(value: string): string {
return value === '' ? 'Unknown' : value; return value === '' ? 'Unknown' : value;
} }
/** /** `Yes (2 hours ago)`: this player was in this exact instance with you before, per VRCNext's timeline. */
* `Yes (met 3× before, last 26/09/2026, 21:40 in Club X; 2 of the last 3 in matching instances)`. function rejoinText(rejoin: Rejoin): string {
* Counts come from VRCNext: `meetCount` from its meet_again event, the rest from the ten most if (rejoin.seenHere === undefined) return 'Unknown';
* recent timeline events it returned, which is why the matching count says how many events it looked at. if (!rejoin.seenHere) return 'No';
*/ return rejoin.lastAt === undefined ? 'Yes' : `Yes (${timeAgo(rejoin.lastAt)})`;
function rejoinText(history: MeetHistory, filtered: boolean): string {
if (history.metBefore === undefined) return 'Unknown';
if (!history.metBefore) return 'No (first time)';
const bits: string[] = [];
if (history.meetCount > 0) bits.push(`met ${String(history.meetCount)}× before`);
if (history.lastMet !== undefined) {
const when = new Date(history.lastMet.at).toLocaleString();
bits.push(`last ${when}${history.lastMet.worldName === '' ? '' : ` in ${history.lastMet.worldName}`}`);
}
if (filtered && history.matchingBefore !== undefined && history.windowSize > 0) {
bits.push(`${String(history.matchingBefore)} of the last ${String(history.windowSize)} in matching instances`);
}
return bits.length === 0 ? 'Yes' : `Yes (${bits.join('; ')})`;
} }
/** The report as the spec lays it out, one fact per line. */ /** The report as the spec lays it out, one fact per line. */
@@ -75,7 +60,7 @@ export function reportLines(report: Report): readonly string[] {
`Avatar Quest Performance Rank: ${rank(facts.avatar?.quest ?? '')}`, `Avatar Quest Performance Rank: ${rank(facts.avatar?.quest ?? '')}`,
]; ];
if (report.groupFilter !== '') lines.push(`In Group: ${yesNo(facts.inGroup, 'Unknown (only visible memberships count)')}`); if (report.groupFilter !== '') lines.push(`In Group: ${yesNo(facts.inGroup, 'Unknown (only visible memberships count)')}`);
lines.push(`Rejoin?: ${rejoinText(report.facts.history, report.filtered)}`); lines.push(`Rejoin?: ${rejoinText(report.facts.rejoin)}`);
return lines; return lines;
} }
@@ -88,7 +73,7 @@ export function reportSummary(report: Report): string {
`Quest ${rank(facts.avatar?.quest ?? '').replace('Unknown', '?')}`, `Quest ${rank(facts.avatar?.quest ?? '').replace('Unknown', '?')}`,
]; ];
if (report.groupFilter !== '') bits.push(`group: ${yesNo(facts.inGroup, '?')}`); if (report.groupFilter !== '') bits.push(`group: ${yesNo(facts.inGroup, '?')}`);
bits.push(report.facts.history.metBefore === undefined ? 'history ?' : (report.facts.history.metBefore ? 'rejoin' : 'new')); bits.push(report.facts.rejoin.seenHere === undefined ? 'rejoin ?' : (report.facts.rejoin.seenHere ? 'rejoin' : 'new'));
return `${report.joiner.name} joined · ${bits.join(' · ')}`; return `${report.joiner.name} joined · ${bits.join(' · ')}`;
} }
+2 -2
View File
@@ -71,7 +71,7 @@ export class ReportPanel {
icon: 'build', icon: 'build',
children: [ children: [
k.description('A test report uses the current instance and your own name, and goes to every enabled channel.'), k.description('A test report uses the current instance and your own name, and goes to every enabled channel.'),
k.description('Rejoin history comes from VRCNext’s own timeline, so there is nothing to clear here.'), k.description('Rejoin is answered from VRCNext’s own timeline: was this player in this exact instance with you before.'),
k.buttonRow( k.buttonRow(
k.button({ label: 'Send test notification', icon: 'send', onClick: () => { void this.#deps.sendTest(); } }), k.button({ label: 'Send test notification', icon: 'send', onClick: () => { void this.#deps.sendTest(); } }),
), ),
@@ -114,7 +114,7 @@ export class ReportPanel {
return k.row({ return k.row({
label: `${time} · ${report.joiner.name}`, label: `${time} · ${report.joiner.name}`,
detail: lines.slice(1).join(' · '), detail: lines.slice(1).join(' · '),
value: report.facts.history.metBefore === true ? k.badge('warn', 'Rejoin') : k.badge('accent', report.facts.history.metBefore === false ? 'New' : 'History ?'), value: report.facts.rejoin.seenHere === true ? k.badge('warn', 'Rejoin') : k.badge('accent', report.facts.rejoin.seenHere === false ? 'New' : 'Rejoin ?'),
}); });
}); });
} }