diff --git a/README.md b/README.md index bbf44ca..29a186c 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Player "Tupper" joined Avatar PC Performance Rank: Good Avatar Quest Performance Rank: Poor In Group: Yes -Rejoin?: Yes (2× before, last 26/09/2026, 21:40:12) +Rejoin?: Yes (met 2× before; last 26/09/2026, 21:40:12 in Lovers Wedding; 1 of the last 3 in matching instances) ``` `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. | | 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". | -| Rejoin | The plugin's own memory of joins to matching instances, kept in a hidden setting, capped at 2000 players. | Cleared with the button on the plugin's tab. | +| 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. | Everything waits at most the configured number of seconds, then reports what it has, with `Unknown` for the rest. @@ -49,8 +49,8 @@ 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 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 -the settle window after it (or after a world change) as "already here": they are remembered for -the rejoin check but not reported. +the settle window after it (or after a world change) as "already here": not reported, and nothing +is lost because VRCNext records the meeting itself. ## Layout and permissions diff --git a/main.ts b/main.ts index ee86d74..9f016cc 100644 --- a/main.ts +++ b/main.ts @@ -12,8 +12,7 @@ import { definePlugin, type PluginContext, type PluginId } from '@vrcnext/plugin-api'; import { FactCollector, type Joiner } from './src/collector.js'; -import { filterFrom, instanceMatches } from './src/filters.js'; -import { JoinMemory } from './src/memory.js'; +import { filterFrom, instanceMatches, type InstanceFilter } from './src/filters.js'; import { notifyAll, type Report } from './src/notify.js'; import { ReportPanel } from './src/panel.js'; import { settings } from './src/settings.js'; @@ -25,10 +24,13 @@ type Ctx = PluginContext; const JOIN_EVENT = 'gl_player_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 { readonly #ctx: Ctx; readonly #collector: FactCollector; - readonly #memory: JoinMemory; readonly #panel: ReportPanel; /** * The signed-in account, from the `vrcUser` event VRCNext pushes after login and whenever the @@ -47,9 +49,7 @@ class ClubSecurity { constructor(ctx: Ctx) { this.#ctx = ctx; this.#collector = new FactCollector(ctx); - this.#memory = new JoinMemory(ctx.settings); this.#panel = new ReportPanel(ctx, { - memory: this.#memory, currentInstance: () => this.#collector.instance, sendTest: () => this.#sendTest(), }); @@ -94,14 +94,13 @@ class ClubSecurity { if (this.#inFlight.has(key)) return; this.#inFlight.add(key); try { - const previous = await this.#memory.record(key, joiner.name, instance.location); if (Date.now() < this.#settledAt) { - this.#ctx.logger.debug(`${joiner.name} was already here when you joined; remembered, not reported.`); - this.#panel.refresh(); + // VRCNext records the meeting itself, so nothing is lost by not reporting it. + this.#ctx.logger.debug(`${joiner.name} was already here when you joined; not reported.`); return; } - const facts = await this.#collector.collect(joiner, filter.groupId, values.collectTimeoutSecs * 1000); - const report: Report = { at: Date.now(), joiner, instance, facts, groupFilter: filter.groupId, previous }; + 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) }; this.#panel.push(report); this.#ctx.logger.info(`Reported ${joiner.name}: 18+ ${String(facts.ageVerified ?? '?')}, PC ${facts.avatar?.pc ?? '?'}, Quest ${facts.avatar?.quest ?? '?'}.`); await notifyAll(this.#ctx, report); @@ -130,15 +129,8 @@ class ClubSecurity { const name = me?.displayName ?? self.name; const joiner: Joiner = { name: name === '' ? 'Test player' : name, userId: me?.id ?? '' }; const filter = filterFrom(this.#ctx.settings.values); - const facts = await this.#collector.collect(joiner, filter.groupId, 10_000); - const report: Report = { - at: Date.now(), - joiner, - instance, - facts, - groupFilter: filter.groupId, - previous: this.#memory.previous(joiner.userId === '' ? `name:${joiner.name}` : joiner.userId), - }; + 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) }; await notifyAll(this.#ctx, report); this.#ctx.notifications.toast({ message: 'Test report sent to every enabled channel.' }); } diff --git a/plugin.json b/plugin.json index 12aaa04..055d89d 100644 --- a/plugin.json +++ b/plugin.json @@ -3,10 +3,14 @@ "name": "Club Security", "version": "1.0.0", "apiVersion": "^0.2.0", - "description": "When someone joins your instance, gathers age verification, avatar performance, group membership and rejoin 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 VRCNext's meeting history, then notifies you in-app, on the desktop, in VR or on Discord.", "author": "vrcnext-plugins", "homepage": "https://github.com/vrcnext-plugins/vrcnext-plugin-system/tree/main/examples/club-security", - "tags": ["notifications", "privacy", "activity"], + "tags": [ + "notifications", + "privacy", + "activity" + ], "permissions": [ "host:events", "host:actions", @@ -17,17 +21,22 @@ ], "optionalPermissions": [], "actions": [ + "getTimelineForUser", + "vrcGetAvatarDetail", "vrcGetCurrentInstance", "vrcGetGroupsForNetwork", - "vrcGetInstanceAvatars", - "vrcGetAvatarDetail" + "vrcGetInstanceAvatars" ], "events": [ - "vrcUser", + "timelineEvent", + "timelineForUser", + "vrcAvatarDetail", "vrcCurrentInstance", "vrcGroupsForNetwork", "vrcInstanceAvatarFound", - "vrcAvatarDetail" + "vrcUser" ], - "hosts": ["discord.com"] + "hosts": [ + "discord.com" + ] } diff --git a/src/collector.ts b/src/collector.ts index 6d3ea1c..1ba1377 100644 --- a/src/collector.ts +++ b/src/collector.ts @@ -15,6 +15,8 @@ import type { EventBus, PluginContext } from '@vrcnext/plugin-api'; +import type { InstanceFilter } from './filters.js'; +import { summarise, toMeetEvent, toTimelineEntries, type MeetEvent, type MeetHistory } from './history.js'; import type { Settings } from './settings.js'; import { profileLoaded, @@ -45,6 +47,7 @@ export interface Facts { readonly avatar: AvatarFacts | undefined; /** `undefined` when no group filter is configured. */ readonly inGroup: boolean | undefined; + readonly history: MeetHistory; } type Ctx = PluginContext; @@ -92,6 +95,8 @@ export class FactCollector { #latest: CurrentInstance | undefined; /** Performance ranks by avatar id, so a popular avatar is only fetched once. */ readonly #perfCache = new Map(); + /** The latest first_meet / meet_again VRCNext pushed per user; it can arrive before the join line. */ + readonly #meets = new Map(); constructor(ctx: Ctx) { this.#ctx = ctx; @@ -99,6 +104,10 @@ export class FactCollector { const instance = toCurrentInstance(payload); 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) => { const perf = toAvatarPerformance(payload); if (perf !== undefined) { @@ -112,12 +121,14 @@ export class FactCollector { } /** Runs every lookup in parallel and returns whatever arrived before the deadline. */ - async collect(joiner: Joiner, groupId: string, deadlineMs: number): Promise { + async collect(joiner: Joiner, filter: InstanceFilter, deadlineMs: number): Promise { const signal = this.#ctx.signal; - const [profile, inGroup, avatar] = await Promise.all([ + const joinedAt = Date.now(); + const [profile, inGroup, avatar, history] = await Promise.all([ this.#profile(joiner, deadlineMs, signal), - groupId === '' ? Promise.resolve(undefined) : this.#membership(joiner, groupId, deadlineMs, signal), + filter.groupId === '' ? Promise.resolve(undefined) : this.#membership(joiner, filter.groupId, deadlineMs, signal), this.#avatar(joiner, deadlineMs, signal), + this.#history(joiner, filter, joinedAt, { ms: deadlineMs, signal }), ]); return { ageVerified: profile === undefined ? undefined : profile.ageVerified, @@ -125,9 +136,39 @@ export class FactCollector { platform: profile?.platform ?? '', avatar, inGroup, + history, }; } + /** + * VRCNext's own record of earlier meetings: the live meet event for this join, plus the user's + * recent timeline. Legacy accounts without a `usr_` id have neither. + */ + async #history( + joiner: Joiner, + filter: InstanceFilter, + joinedAt: number, + window: { readonly ms: number; readonly signal: AbortSignal }, + ): Promise { + 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, + 'timelineForUser', + (payload) => toTimelineEntries(payload, joiner.userId), + { ms, signal }, + ); + this.#ctx.bridge.send('getTimelineForUser', { userId: joiner.userId }); + const [meet, entries] = await Promise.all([meetPending, timelinePending]); + return summarise(meet, entries, filter, joinedAt); + } + async #profile(joiner: Joiner, ms: number, signal: AbortSignal): Promise { const current = this.#latest === undefined ? undefined : findUser(this.#latest, joiner); if (current !== undefined && profileLoaded(current)) return current; diff --git a/src/filters.ts b/src/filters.ts index 2817093..f1673de 100644 --- a/src/filters.ts +++ b/src/filters.ts @@ -4,9 +4,41 @@ import type { SettingsValues } from '@vrcnext/plugin-api'; -import type { CurrentInstance } from './vrcnext-data.js'; +import { groupIdOf } from './vrcnext-data.js'; import type { Settings } from './settings.js'; +/** The three things a filter looks at; both a live instance and a past location provide them. */ +export interface InstanceShape { + readonly worldId: string; + readonly instanceType: string; + readonly groupId: string; +} + +/** + * VRCNext's `ParseLocation`, ported: the instance type a location string encodes. Needed for + * past locations from the timeline, which carry no parsed type. + */ +export function instanceTypeOf(location: string): string { + const instance = location.split(':')[1] ?? ''; + if (instance.includes('~private(')) return instance.includes('~canRequestInvite') ? 'invite_plus' : 'private'; + if (instance.includes('~friends+(')) return 'friends+'; + if (instance.includes('~friends(')) return 'friends'; + if (instance.includes('~hidden(')) return 'hidden'; + if (instance.includes('~group(')) { + const access = /groupAccessType\(([^)]+)\)/.exec(instance)?.[1] ?? ''; + if (access === 'public') return 'group-public'; + if (access === 'plus') return 'group-plus'; + if (access === 'members') return 'group-members'; + return 'group'; + } + return 'public'; +} + +export function shapeOfLocation(location: string): InstanceShape { + const worldId = location.split(':')[0] ?? ''; + return { worldId: worldId.startsWith('wrld_') ? worldId : '', instanceType: instanceTypeOf(location), groupId: groupIdOf(location) }; +} + export interface InstanceFilter { readonly instanceTypes: readonly string[]; readonly groupId: string; @@ -34,7 +66,7 @@ function typeMatches(wanted: readonly string[], actual: string): boolean { return wanted.some((w) => w === type || (w === 'group' && type.startsWith('group'))); } -export function instanceMatches(filter: InstanceFilter, instance: CurrentInstance): boolean { +export function instanceMatches(filter: InstanceFilter, instance: InstanceShape): boolean { if (!typeMatches(filter.instanceTypes, instance.instanceType)) return false; const groupId = filter.groupId.toLowerCase(); if (groupId !== '' && instance.groupId.toLowerCase() !== groupId) return false; diff --git a/src/history.ts b/src/history.ts new file mode 100644 index 0000000..7bc71d2 --- /dev/null +++ b/src/history.ts @@ -0,0 +1,116 @@ +/** + * Whether a joiner has been met before, from VRCNext's own timeline. + * + * VRCNext keeps a `known_users` table and, on every join, records a `first_meet` or `meet_again` + * timeline event for that player (pushed live as `timelineEvent`, with `meetCount` on repeats). + * `getTimelineForUser` returns the ten most recent timeline events involving the player, each + * with a timestamp and the location it happened in. Both survive VRCNext and VRChat restarts and + * 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 { + readonly type: string; + readonly timestamp: string; + readonly worldName: string; + readonly location: string; +} + +export interface MeetHistory { + /** `undefined` when VRCNext said nothing in time. */ + readonly metBefore: boolean | undefined; + /** VRCNext's count of earlier meetings, when it reported one. */ + readonly meetCount: number; + 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 = { + metBefore: undefined, + meetCount: 0, + lastMet: undefined, + matchingBefore: undefined, + windowSize: 0, +}; + +function rec(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null ? (value as Record) : undefined; +} + +function str(value: unknown): string { + 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. */ +export function toTimelineEntries(payload: unknown, userId: string): readonly TimelineEntry[] | undefined { + const r = rec(payload); + if (r === undefined || str(r['userId']) !== userId) return undefined; + const events = Array.isArray(r['events']) ? r['events'] : []; + return events.flatMap((e) => { + const entry = rec(e); + if (entry === undefined) return []; + return [{ + type: str(entry['type']), + timestamp: str(entry['timestamp']), + worldName: str(entry['worldName']), + location: str(entry['location']), + }]; + }); +} + +/** + * Combines the live meet event with the user's timeline into one answer. + * + * Events from the current join are excluded by timestamp: anything at or after `joinedAt` + * describes this visit, not an earlier one. + */ +export function summarise( + meet: MeetEvent | undefined, + entries: readonly TimelineEntry[] | undefined, + filter: InstanceFilter, + joinedAt: number, +): MeetHistory { + const earlier = (entries ?? []).filter((e) => { + const at = Date.parse(e.timestamp); + return Number.isFinite(at) && at < joinedAt - 5_000; + }); + earlier.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); + + const metBefore = meet !== undefined ? meet.type === 'meet_again' : (entries === undefined ? undefined : earlier.length > 0); + const last = earlier[0]; + const matching = entries === undefined + ? undefined + : earlier.filter((e) => e.location !== '' && instanceMatches(filter, shapeOfLocation(e.location))).length; + return { + metBefore, + meetCount: meet?.meetCount ?? 0, + lastMet: last === undefined ? undefined : { at: last.timestamp, worldName: last.worldName }, + matchingBefore: matching, + windowSize: earlier.length, + }; +} diff --git a/src/memory.ts b/src/memory.ts deleted file mode 100644 index 1869e36..0000000 --- a/src/memory.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Who has joined a matching instance before. - * - * Persisted in a hidden string setting as JSON, because the plugin API offers settings as its - * only durable store. Capped so it cannot grow without bound; the oldest entries fall off. - */ - -import type { SettingsStore } from '@vrcnext/plugin-api'; - -import type { Settings } from './settings.js'; - -export interface SeenEntry { - readonly name: string; - readonly joins: number; - readonly firstSeen: number; - readonly lastSeen: number; - readonly lastLocation: string; -} - -const MAX_ENTRIES = 2000; - -type SeenMap = Record; - -function parse(raw: string): SeenMap { - try { - const value: unknown = JSON.parse(raw); - if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}; - const out: SeenMap = {}; - for (const [key, entry] of Object.entries(value as Record)) { - if (typeof entry !== 'object' || entry === null) continue; - const e = entry as Record; - out[key] = { - name: typeof e['name'] === 'string' ? e['name'] : '', - joins: typeof e['joins'] === 'number' ? e['joins'] : 1, - firstSeen: typeof e['firstSeen'] === 'number' ? e['firstSeen'] : 0, - lastSeen: typeof e['lastSeen'] === 'number' ? e['lastSeen'] : 0, - lastLocation: typeof e['lastLocation'] === 'string' ? e['lastLocation'] : '', - }; - } - return out; - } catch { - return {}; - } -} - -function trimmed(map: SeenMap): SeenMap { - const entries = Object.entries(map); - if (entries.length <= MAX_ENTRIES) return map; - entries.sort((a, b) => b[1].lastSeen - a[1].lastSeen); - return Object.fromEntries(entries.slice(0, MAX_ENTRIES)); -} - -export class JoinMemory { - readonly #store: SettingsStore; - #map: SeenMap; - - constructor(store: SettingsStore) { - this.#store = store; - this.#map = parse(store.get('memory')); - } - - get size(): number { - return Object.keys(this.#map).length; - } - - /** The entry as it was before this join, or `undefined` for a first-timer. */ - previous(key: string): SeenEntry | undefined { - return this.#map[key]; - } - - /** Records a join and persists. Returns the entry as it was before. */ - async record(key: string, name: string, location: string): Promise { - const before = this.#map[key]; - const now = Date.now(); - this.#map[key] = { - name, - joins: (before?.joins ?? 0) + 1, - firstSeen: before?.firstSeen ?? now, - lastSeen: now, - lastLocation: location, - }; - this.#map = trimmed(this.#map); - await this.#store.set('memory', JSON.stringify(this.#map)); - return before; - } - - async clear(): Promise { - this.#map = {}; - await this.#store.set('memory', '{}'); - } -} diff --git a/src/notify.ts b/src/notify.ts index 295e972..d3a99e0 100644 --- a/src/notify.ts +++ b/src/notify.ts @@ -13,7 +13,7 @@ import type { PluginContext, SettingsValues } from '@vrcnext/plugin-api'; import type { Facts, Joiner } from './collector.js'; -import type { SeenEntry } from './memory.js'; +import type { MeetHistory } from './history.js'; import type { Settings } from './settings.js'; import type { CurrentInstance } from './vrcnext-data.js'; @@ -24,7 +24,8 @@ export interface Report { readonly facts: Facts; /** Whether a group filter was active, so the "In Group" line is only shown when it means something. */ readonly groupFilter: string; - readonly previous: SeenEntry | undefined; + /** Whether any filter was active, so the matching-instance count is only shown when it means something. */ + readonly filtered: boolean; } type Ctx = PluginContext; @@ -41,10 +42,24 @@ function rank(value: string): string { return value === '' ? 'Unknown' : value; } -function rejoinText(previous: SeenEntry | undefined): string { - if (previous === undefined) return 'No (first time)'; - const when = new Date(previous.lastSeen).toLocaleString(); - return `Yes (${String(previous.joins)}× before, last ${when})`; +/** + * `Yes (met 3× before, last 26/09/2026, 21:40 in Club X; 2 of the last 3 in matching instances)`. + * Counts come from VRCNext: `meetCount` from its meet_again event, the rest from the ten most + * recent timeline events it returned, which is why the matching count names its window. + */ +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. */ @@ -60,7 +75,7 @@ export function reportLines(report: Report): readonly string[] { `Avatar Quest Performance Rank: ${rank(facts.avatar?.quest ?? '')}`, ]; if (report.groupFilter !== '') lines.push(`In Group: ${yesNo(facts.inGroup, 'Unknown (only visible memberships count)')}`); - lines.push(`Rejoin?: ${rejoinText(report.previous)}`); + lines.push(`Rejoin?: ${rejoinText(report.facts.history, report.filtered)}`); return lines; } @@ -73,7 +88,7 @@ export function reportSummary(report: Report): string { `Quest ${rank(facts.avatar?.quest ?? '').replace('Unknown', '?')}`, ]; if (report.groupFilter !== '') bits.push(`group: ${yesNo(facts.inGroup, '?')}`); - bits.push(report.previous === undefined ? 'new' : 'rejoin'); + bits.push(report.facts.history.metBefore === undefined ? 'history ?' : (report.facts.history.metBefore ? 'rejoin' : 'new')); return `${report.joiner.name} joined · ${bits.join(' · ')}`; } diff --git a/src/panel.ts b/src/panel.ts index 515f193..4cab93d 100644 --- a/src/panel.ts +++ b/src/panel.ts @@ -6,7 +6,6 @@ import type { PluginContext } from '@vrcnext/plugin-api'; import { describeFilter, filterFrom, instanceMatches } from './filters.js'; -import type { JoinMemory } from './memory.js'; import { reportLines, type Report } from './notify.js'; import type { Settings } from './settings.js'; import type { CurrentInstance } from './vrcnext-data.js'; @@ -16,7 +15,6 @@ type Ctx = PluginContext; const MAX_SHOWN = 25; export interface PanelDeps { - readonly memory: JoinMemory; readonly currentInstance: () => CurrentInstance | undefined; readonly sendTest: () => Promise; } @@ -73,13 +71,9 @@ export class ReportPanel { icon: 'build', children: [ 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.buttonRow( k.button({ label: 'Send test notification', icon: 'send', onClick: () => { void this.#deps.sendTest(); } }), - k.button({ - label: 'Forget who joined before', - icon: 'delete_sweep', - onClick: () => { void this.#clearMemory(); }, - }), ), ], }); @@ -108,7 +102,6 @@ export class ReportPanel { value: instance === undefined ? undefined : k.badge(matches ? 'ok' : 'neutral', matches ? 'Matches' : 'Filtered out'), }), k.row({ label: 'Channels', detail: channels.length === 0 ? 'none enabled' : channels.join(', ') }), - k.row({ label: 'Players remembered', value: k.valueText(String(this.#deps.memory.size)) }), ]; } @@ -121,21 +114,9 @@ export class ReportPanel { return k.row({ label: `${time} · ${report.joiner.name}`, detail: lines.slice(1).join(' · '), - value: report.previous === undefined ? k.badge('accent', 'New') : k.badge('warn', 'Rejoin'), + value: report.facts.history.metBefore === true ? k.badge('warn', 'Rejoin') : k.badge('accent', report.facts.history.metBefore === false ? 'New' : 'History ?'), }); }); } - async #clearMemory(): Promise { - const ok = await this.#ctx.notifications.confirm({ - title: 'Forget join history', - message: 'Every player will count as a first-time joiner again. Continue?', - confirmLabel: 'Forget', - icon: 'delete_sweep', - }); - if (!ok) return; - await this.#deps.memory.clear(); - this.#ctx.notifications.toast({ message: 'Join history cleared.' }); - this.refresh(); - } } diff --git a/src/settings.ts b/src/settings.ts index 85ce4ce..b5939e7 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -97,13 +97,6 @@ export const settings = { default: '', placeholder: 'https://discord.com/api/webhooks/…', }, - memory: { - kind: 'string', - label: 'Join memory', - description: 'Internal: who has joined a matching instance before.', - default: '{}', - hidden: true, - }, } as const satisfies SettingsSchema; export type Settings = typeof settings;