@yyyu-star/arcade-kit 0.11.2 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +57 -0
- package/dist/share.d.ts +106 -0
- package/dist/share.d.ts.map +1 -0
- package/dist/share.js +378 -0
- package/dist/share.js.map +1 -0
- package/dist/social.d.ts +126 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +346 -0
- package/dist/social.js.map +1 -0
- package/package.json +9 -1
- package/src/share.ts +435 -0
- package/src/social.ts +411 -0
package/src/social.ts
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 社交第一期(0.12.0,仓库根 docs/social.md 1.2、1.3):游戏页面开着时报"我在哪"、收好友的邀请,在页面顶上弹一条"老王叫你来打牌 [去] [不了]"。
|
|
3
|
+
*
|
|
4
|
+
* - 只在有平台、已登录时挂(`mountSocial` 先看 `detectPlatform`):单独部署、没登录一个请求都不发。
|
|
5
|
+
* - 省平台的写入:位置只在页面在前台时每 2 分钟报一次(平台再按 2 分钟节流,和 last_seen_at 一起写);邀请每 30 秒问一次(只读)。
|
|
6
|
+
* 切到后台就停,回到前台到点了马上补一次。不用 Web Push。
|
|
7
|
+
* - 没有自由文字:横条上只有对方的昵称(平台的账号名)和游戏名(游戏给的表),都按纯文本放进去。
|
|
8
|
+
* - 这个文件只在调用时碰 DOM,import 时不碰;接口的小函数和挑邀请的逻辑是纯的,测试在 test/social.test.ts(假 fetch)。
|
|
9
|
+
* - 接口形状是主线定的(docs/platform-backend.md 里平台写细节):都要登录,写的请求带 `x-arcade: 1`、同源(平台的 CSRF 规矩)。
|
|
10
|
+
*/
|
|
11
|
+
import { detectPlatform, type PlatformInfo } from './client.js';
|
|
12
|
+
|
|
13
|
+
/** 报位置:在大厅、在玩某个游戏、在某个好友房里(seats 是"3/4"这样的人数) */
|
|
14
|
+
export interface Presence {
|
|
15
|
+
place: 'lobby' | 'game' | 'room';
|
|
16
|
+
game?: string;
|
|
17
|
+
room?: string;
|
|
18
|
+
seats?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 收到的一条邀请;until 是过期时间(毫秒时间戳,平台给字符串也认) */
|
|
22
|
+
export interface Invite {
|
|
23
|
+
id: string;
|
|
24
|
+
from: { id: string; name: string };
|
|
25
|
+
game: string;
|
|
26
|
+
room: string;
|
|
27
|
+
until: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 好友列表的一项。平台每项是公开资料 `{ userId, displayName, avatar }`(docs/platform-backend.md 15.4,只增不改)加 online、where
|
|
32
|
+
* (对方关了"让好友看到我在哪"就没有 where);kit 统一成 id、name(0.12.1),平台原样的字段也留着。发邀请的 `to` 用 id
|
|
33
|
+
*/
|
|
34
|
+
export interface Friend {
|
|
35
|
+
id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
online: boolean;
|
|
38
|
+
where?: Presence;
|
|
39
|
+
avatar?: unknown;
|
|
40
|
+
[k: string]: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 平台的一项 → Friend:认 userId / displayName(平台的形状),也认 id / name;没有 id 的丢掉 */
|
|
44
|
+
export function parseFriend(x: unknown): Friend | null {
|
|
45
|
+
if (!x || typeof x !== 'object') return null;
|
|
46
|
+
const f = x as Record<string, unknown>;
|
|
47
|
+
const id = typeof f['userId'] === 'string' ? f['userId'] : typeof f['id'] === 'string' ? f['id'] : null;
|
|
48
|
+
if (!id) return null;
|
|
49
|
+
const name = typeof f['displayName'] === 'string' ? f['displayName'] : typeof f['name'] === 'string' ? f['name'] : '';
|
|
50
|
+
return { ...f, id, name, online: f['online'] === true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ApiOptions {
|
|
54
|
+
fetchImpl?: typeof fetch;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 发邀请的结果:limit 是超了次数(每人每天 50 条、同一好友 1 分钟 1 条),blocked 是被屏蔽或不是好友 */
|
|
58
|
+
export type SendInviteResult =
|
|
59
|
+
| { ok: true; id: string }
|
|
60
|
+
| { ok: false; error: 'limit' | 'blocked' | 'login' | 'failed'; retryAfter?: number };
|
|
61
|
+
|
|
62
|
+
/** 位置每 2 分钟一次、邀请每 30 秒一次(docs/social.md) */
|
|
63
|
+
export const PRESENCE_MS = 120_000;
|
|
64
|
+
export const INVITE_POLL_MS = 30_000;
|
|
65
|
+
|
|
66
|
+
const GAME_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
67
|
+
const ROOM_CODE = /^[A-Za-z0-9]{4,16}$/;
|
|
68
|
+
|
|
69
|
+
function api(opts: ApiOptions): typeof fetch {
|
|
70
|
+
return opts.fetchImpl ?? ((...a: Parameters<typeof fetch>) => fetch(...a));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 写的请求:同源 cookie、`x-arcade: 1`(平台的 CSRF 规矩) */
|
|
74
|
+
function write(method: string, body?: unknown, keepalive = false): RequestInit {
|
|
75
|
+
return {
|
|
76
|
+
method,
|
|
77
|
+
credentials: 'same-origin',
|
|
78
|
+
keepalive,
|
|
79
|
+
headers: { 'x-arcade': '1', accept: 'application/json', ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
|
|
80
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const READ: RequestInit = { credentials: 'same-origin', headers: { accept: 'application/json' } };
|
|
85
|
+
|
|
86
|
+
/** 过期时间:数字(毫秒)或能解析的时间字符串;都不是返回 NaN */
|
|
87
|
+
export function untilOf(x: unknown): number {
|
|
88
|
+
if (typeof x === 'number') return x;
|
|
89
|
+
if (typeof x === 'string') return /^\d+$/.test(x) ? Number(x) : Date.parse(x);
|
|
90
|
+
return Number.NaN;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 平台回的邀请列表:形状不对的项丢掉(游戏 id、房间码按格式查,跳转地址只从这两样拼) */
|
|
94
|
+
export function parseInvites(body: unknown): Invite[] {
|
|
95
|
+
const list = (body as { invites?: unknown } | null)?.invites;
|
|
96
|
+
if (!Array.isArray(list)) return [];
|
|
97
|
+
const out: Invite[] = [];
|
|
98
|
+
for (const x of list as unknown[]) {
|
|
99
|
+
const v = x as Partial<Record<keyof Invite, unknown>> | null;
|
|
100
|
+
const from = v?.from as { id?: unknown; name?: unknown } | undefined;
|
|
101
|
+
const until = untilOf(v?.until);
|
|
102
|
+
if (
|
|
103
|
+
typeof v?.id === 'string' && v.id &&
|
|
104
|
+
typeof from?.name === 'string' && from.name &&
|
|
105
|
+
typeof v.game === 'string' && GAME_ID.test(v.game) &&
|
|
106
|
+
typeof v.room === 'string' && ROOM_CODE.test(v.room) &&
|
|
107
|
+
Number.isFinite(until)
|
|
108
|
+
) {
|
|
109
|
+
out.push({ id: v.id, from: { id: String(from.id ?? ''), name: from.name }, game: v.game, room: v.room, until });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 现在该弹哪一条:没过期、没弹过(skip)、不是我已经在的那个房间的;先到期的先弹
|
|
117
|
+
*/
|
|
118
|
+
export function pickInvite(list: readonly Invite[], now: number, skip: ReadonlySet<string>, here?: Presence | null): Invite | null {
|
|
119
|
+
let best: Invite | null = null;
|
|
120
|
+
for (const v of list) {
|
|
121
|
+
if (v.until <= now || skip.has(v.id)) continue;
|
|
122
|
+
if (here?.place === 'room' && here.game === v.game && here.room === v.room) continue;
|
|
123
|
+
if (!best || v.until < best.until) best = v;
|
|
124
|
+
}
|
|
125
|
+
return best;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 点"去"跳到哪:`/games/<id>/?room=<码>`(站点根下,和自己挂在哪无关) */
|
|
129
|
+
export function inviteUrl(v: Pick<Invite, 'game' | 'room'>): string {
|
|
130
|
+
return `/games/${encodeURIComponent(v.game)}/?room=${encodeURIComponent(v.room)}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 横条上的那句话:游戏名不认识就说"一起玩" */
|
|
134
|
+
export function inviteText(v: Pick<Invite, 'from' | 'game'>, gameName?: (id: string) => string | undefined): string {
|
|
135
|
+
const who = [...v.from.name].slice(0, 12).join('');
|
|
136
|
+
const g = gameName?.(v.game);
|
|
137
|
+
return g ? `${who}叫你来${g}` : `${who}叫你一起玩`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 报一次位置。返回 HTTP 状态(0 = 没连上);平台回 204 */
|
|
141
|
+
export async function postPresence(p: Presence, opts: ApiOptions = {}): Promise<number> {
|
|
142
|
+
try {
|
|
143
|
+
return (await api(opts)('/api/presence', write('POST', p))).status;
|
|
144
|
+
} catch {
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 问一次有没有邀请。没连上、不是 200 回 null(和"没有邀请"分开:401 时调用方要停) */
|
|
150
|
+
export async function fetchInvites(opts: ApiOptions = {}): Promise<{ status: number; invites: Invite[] }> {
|
|
151
|
+
try {
|
|
152
|
+
const res = await api(opts)('/api/invites', READ);
|
|
153
|
+
if (res.status !== 200) return { status: res.status, invites: [] };
|
|
154
|
+
return { status: 200, invites: parseInvites(await res.json()) };
|
|
155
|
+
} catch {
|
|
156
|
+
return { status: 0, invites: [] };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** "不了":这条以后不再弹(跳过去之前也调一次,到了游戏页不会再弹同一条) */
|
|
161
|
+
export async function dismissInvite(id: string, opts: ApiOptions & { keepalive?: boolean } = {}): Promise<boolean> {
|
|
162
|
+
try {
|
|
163
|
+
const res = await api(opts)(`/api/invites/${encodeURIComponent(id)}/dismiss`, write('POST', undefined, opts.keepalive));
|
|
164
|
+
return res.ok;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 叫好友来:to 是好友的用户 id,game、room 是我在的房间 */
|
|
171
|
+
export async function sendInvite(inv: { to: string; game: string; room: string }, opts: ApiOptions = {}): Promise<SendInviteResult> {
|
|
172
|
+
let res: Response;
|
|
173
|
+
try {
|
|
174
|
+
res = await api(opts)('/api/invites', write('POST', inv));
|
|
175
|
+
} catch {
|
|
176
|
+
return { ok: false, error: 'failed' };
|
|
177
|
+
}
|
|
178
|
+
if (res.ok) {
|
|
179
|
+
const body = (await res.json().catch(() => null)) as { id?: unknown } | null;
|
|
180
|
+
return typeof body?.id === 'string' ? { ok: true, id: body.id } : { ok: false, error: 'failed' };
|
|
181
|
+
}
|
|
182
|
+
if (res.status === 429) {
|
|
183
|
+
const s = Number(res.headers.get('retry-after'));
|
|
184
|
+
return { ok: false, error: 'limit', ...(Number.isFinite(s) && s > 0 ? { retryAfter: s } : {}) };
|
|
185
|
+
}
|
|
186
|
+
if (res.status === 401) return { ok: false, error: 'login' };
|
|
187
|
+
if (res.status === 403 || res.status === 404) return { ok: false, error: 'blocked' };
|
|
188
|
+
return { ok: false, error: 'failed' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 好友列表(带在线、在哪)。没连上、不是 200 回 null */
|
|
192
|
+
export async function fetchFriends(opts: ApiOptions = {}): Promise<Friend[] | null> {
|
|
193
|
+
try {
|
|
194
|
+
const res = await api(opts)('/api/friends', READ);
|
|
195
|
+
if (res.status !== 200) return null;
|
|
196
|
+
const body = (await res.json()) as { friends?: unknown };
|
|
197
|
+
if (!Array.isArray(body?.friends)) return null;
|
|
198
|
+
return (body.friends as unknown[]).map(parseFriend).filter((f): f is Friend => f !== null);
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** "让好友看到我在哪"的开关 */
|
|
205
|
+
export async function setShowWhere(show: boolean, opts: ApiOptions = {}): Promise<boolean> {
|
|
206
|
+
try {
|
|
207
|
+
return (await api(opts)('/api/me', write('PATCH', { showWhere: show }))).ok;
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ───────────── 挂到页面上(只在调用时碰 DOM) ─────────────
|
|
214
|
+
|
|
215
|
+
export interface SocialOptions extends ApiOptions {
|
|
216
|
+
/**
|
|
217
|
+
* 我现在在哪:每次报之前现问。返回 null 这次不报(比如还没进房)。
|
|
218
|
+
* 房间的人数由游戏给("3/4"),平台不进房间问
|
|
219
|
+
*/
|
|
220
|
+
where(): Presence | null;
|
|
221
|
+
/** 游戏 id → 显示的名字(横条上"叫你来<名字>");kit 不认识任何游戏,没给或不认识的说"一起玩" */
|
|
222
|
+
gameName?(id: string): string | undefined;
|
|
223
|
+
/**
|
|
224
|
+
* 有没有平台、登没登录:`detectPlatform` 的结果。游戏启动时已经问过 `/api/me` 的传进来,省一次请求;
|
|
225
|
+
* 不传就自己问一次
|
|
226
|
+
*/
|
|
227
|
+
platform?: PlatformInfo;
|
|
228
|
+
/** 点"去":默认先告诉平台这条办了,再跳到 `/games/<id>/?room=<码>`;游戏要先确认离开(打到一半)就自己接 */
|
|
229
|
+
onGo?(invite: Invite): void;
|
|
230
|
+
/** 横条的 z-index,默认 1500(在牌桌互动之上,在分享预览之下) */
|
|
231
|
+
zIndex?: number;
|
|
232
|
+
/** 冒烟、调试看的流水:`位置 204`、`邀请 弹 <id>`、`邀请 去`、`邀请 不了`、`邀请 过期` */
|
|
233
|
+
log?(line: string): void;
|
|
234
|
+
/** 测试用:时钟 */
|
|
235
|
+
now?(): number;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface SocialHandle {
|
|
239
|
+
/** 位置变了(进房、换游戏)马上报一次;离上次不到 2 分钟就不报,平台反正也会节流 */
|
|
240
|
+
presenceNow(force?: boolean): void;
|
|
241
|
+
/** 马上问一次邀请(比如刚从后台回来,或者冒烟) */
|
|
242
|
+
pollNow(): void;
|
|
243
|
+
/** 收起横条、停掉计时、解开监听 */
|
|
244
|
+
stop(): void;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const BANNER_ID = 'arcade-invite-banner';
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* 挂上报位置和收邀请。没平台、没登录、平台连不上都回 null(不挂);中途掉了登录(401)自己停
|
|
251
|
+
*/
|
|
252
|
+
export async function mountSocial(opts: SocialOptions): Promise<SocialHandle | null> {
|
|
253
|
+
if (typeof document === 'undefined') return null;
|
|
254
|
+
const info = opts.platform ?? (await detectPlatform(opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}));
|
|
255
|
+
if (info.platform !== true || !info.user) return null;
|
|
256
|
+
return new Social(opts);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
class Social implements SocialHandle {
|
|
260
|
+
private lastPresence = -Infinity;
|
|
261
|
+
private lastPoll = -Infinity;
|
|
262
|
+
private polling = false;
|
|
263
|
+
private stopped = false;
|
|
264
|
+
private readonly skip = new Set<string>();
|
|
265
|
+
private invites: Invite[] = [];
|
|
266
|
+
private shown: { invite: Invite; el: HTMLElement; timer: ReturnType<typeof setTimeout> } | null = null;
|
|
267
|
+
private readonly timer: ReturnType<typeof setInterval>;
|
|
268
|
+
private readonly onVis = (): void => this.tick();
|
|
269
|
+
|
|
270
|
+
constructor(private readonly o: SocialOptions) {
|
|
271
|
+
document.addEventListener('visibilitychange', this.onVis);
|
|
272
|
+
// 一个 5 秒的心跳统一看两件事到没到点,后台时什么都不做
|
|
273
|
+
this.timer = setInterval(() => this.tick(), 5_000);
|
|
274
|
+
this.tick();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private now(): number {
|
|
278
|
+
return this.o.now?.() ?? Date.now();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private visible(): boolean {
|
|
282
|
+
return document.visibilityState === 'visible';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private tick(): void {
|
|
286
|
+
if (this.stopped || !this.visible()) return;
|
|
287
|
+
const t = this.now();
|
|
288
|
+
if (t - this.lastPresence >= PRESENCE_MS) this.presenceNow(true);
|
|
289
|
+
if (t - this.lastPoll >= INVITE_POLL_MS) this.pollNow();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
presenceNow(force = false): void {
|
|
293
|
+
if (this.stopped || !this.visible()) return;
|
|
294
|
+
const t = this.now();
|
|
295
|
+
if (!force && t - this.lastPresence < PRESENCE_MS) return;
|
|
296
|
+
const p = this.o.where();
|
|
297
|
+
if (!p) return;
|
|
298
|
+
this.lastPresence = t;
|
|
299
|
+
void postPresence(p, this.o).then((s) => {
|
|
300
|
+
this.o.log?.(`位置 ${s}`);
|
|
301
|
+
if (s === 401) this.stop();
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
pollNow(): void {
|
|
306
|
+
if (this.stopped || this.polling) return;
|
|
307
|
+
this.polling = true;
|
|
308
|
+
this.lastPoll = this.now();
|
|
309
|
+
void fetchInvites(this.o).then(({ status, invites }) => {
|
|
310
|
+
this.polling = false;
|
|
311
|
+
if (status === 401) return this.stop();
|
|
312
|
+
if (status !== 200) return; // 连不上、忙:留着上次的,到点再问
|
|
313
|
+
this.invites = invites;
|
|
314
|
+
this.showNext();
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private showNext(): void {
|
|
319
|
+
if (this.stopped || this.shown) return;
|
|
320
|
+
const v = pickInvite(this.invites, this.now(), this.skip, this.o.where());
|
|
321
|
+
if (v) this.show(v);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private show(v: Invite): void {
|
|
325
|
+
this.skip.add(v.id);
|
|
326
|
+
document.getElementById(BANNER_ID)?.remove();
|
|
327
|
+
const el = document.createElement('div');
|
|
328
|
+
el.id = BANNER_ID;
|
|
329
|
+
el.setAttribute('role', 'alert');
|
|
330
|
+
el.style.cssText = [
|
|
331
|
+
'position:fixed',
|
|
332
|
+
'left:50%',
|
|
333
|
+
'top:calc(env(safe-area-inset-top, 0px) + 8px)',
|
|
334
|
+
`z-index:${this.o.zIndex ?? 1500}`,
|
|
335
|
+
'display:flex',
|
|
336
|
+
'align-items:center',
|
|
337
|
+
'gap:8px',
|
|
338
|
+
'max-width:calc(100vw - 16px)',
|
|
339
|
+
'box-sizing:border-box',
|
|
340
|
+
'padding:6px 6px 6px 16px',
|
|
341
|
+
'border-radius:28px',
|
|
342
|
+
'background:rgba(28,28,32,.94)',
|
|
343
|
+
'color:#fff',
|
|
344
|
+
'box-shadow:0 6px 24px rgba(0,0,0,.35)',
|
|
345
|
+
'font:600 16px system-ui,"PingFang SC","Microsoft YaHei",sans-serif',
|
|
346
|
+
'transform:translate(-50%,-140%)',
|
|
347
|
+
'opacity:0',
|
|
348
|
+
'transition:transform .25s ease-out,opacity .25s ease-out',
|
|
349
|
+
].join(';');
|
|
350
|
+
const text = document.createElement('span');
|
|
351
|
+
text.textContent = inviteText(v, this.o.gameName);
|
|
352
|
+
text.style.cssText = 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0';
|
|
353
|
+
const btn = (label: string, key: string, primary: boolean, fn: () => void): HTMLButtonElement => {
|
|
354
|
+
const b = document.createElement('button');
|
|
355
|
+
b.type = 'button';
|
|
356
|
+
b.textContent = label;
|
|
357
|
+
b.dataset['key'] = key;
|
|
358
|
+
b.style.cssText = `flex:none;min-width:64px;min-height:44px;padding:0 14px;border-radius:22px;border:0;font:inherit;cursor:pointer;${primary ? 'background:#f5c542;color:#222' : 'background:rgba(255,255,255,.14);color:#fff'}`;
|
|
359
|
+
b.addEventListener('click', fn);
|
|
360
|
+
return b;
|
|
361
|
+
};
|
|
362
|
+
const go = btn('去', 'invite:go', true, () => {
|
|
363
|
+
this.o.log?.('邀请 去');
|
|
364
|
+
this.hide();
|
|
365
|
+
// 先告诉平台办了(keepalive:页面马上要跳走),到了游戏页不会再弹同一条
|
|
366
|
+
void dismissInvite(v.id, { ...this.o, keepalive: true });
|
|
367
|
+
if (this.o.onGo) this.o.onGo(v);
|
|
368
|
+
else location.assign(inviteUrl(v));
|
|
369
|
+
});
|
|
370
|
+
const no = btn('不了', 'invite:no', false, () => {
|
|
371
|
+
this.o.log?.('邀请 不了');
|
|
372
|
+
void dismissInvite(v.id, this.o);
|
|
373
|
+
this.hide();
|
|
374
|
+
this.showNext();
|
|
375
|
+
});
|
|
376
|
+
el.appendChild(text);
|
|
377
|
+
el.appendChild(go);
|
|
378
|
+
el.appendChild(no);
|
|
379
|
+
document.body.appendChild(el);
|
|
380
|
+
requestAnimationFrame(() => {
|
|
381
|
+
el.style.transform = 'translate(-50%,0)';
|
|
382
|
+
el.style.opacity = '1';
|
|
383
|
+
});
|
|
384
|
+
// 过期就收起(不告诉平台:它自己按 until 不再给)
|
|
385
|
+
const timer = setTimeout(() => {
|
|
386
|
+
this.o.log?.('邀请 过期');
|
|
387
|
+
this.hide();
|
|
388
|
+
this.showNext();
|
|
389
|
+
}, Math.max(0, v.until - this.now()));
|
|
390
|
+
this.shown = { invite: v, el, timer };
|
|
391
|
+
this.o.log?.(`邀请 弹 ${v.id}`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private hide(): void {
|
|
395
|
+
if (!this.shown) return;
|
|
396
|
+
const { el, timer } = this.shown;
|
|
397
|
+
clearTimeout(timer);
|
|
398
|
+
this.shown = null;
|
|
399
|
+
el.style.transform = 'translate(-50%,-140%)';
|
|
400
|
+
el.style.opacity = '0';
|
|
401
|
+
setTimeout(() => el.remove(), 300);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
stop(): void {
|
|
405
|
+
if (this.stopped) return;
|
|
406
|
+
this.stopped = true;
|
|
407
|
+
clearInterval(this.timer);
|
|
408
|
+
document.removeEventListener('visibilitychange', this.onVis);
|
|
409
|
+
this.hide();
|
|
410
|
+
}
|
|
411
|
+
}
|