feat: use wx.createUserInfoButton to get weixin's avarta
This commit is contained in:
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* PrivacyPopup.js
|
||||
* Privacy authorization popup for WeChat mini-game.
|
||||
*
|
||||
* When a privacy-sensitive API (e.g. wx.getUserInfo) is called, WeChat
|
||||
* triggers `wx.onNeedPrivacyAuthorization`. This popup shows a compliant
|
||||
* dialog that explains what data we collect and lets the user agree or
|
||||
* decline before we call `resolve({ buttonAction: 'agree' })`.
|
||||
*
|
||||
* Compliance notes (WeChat 2024+):
|
||||
* - Must clearly state what data is collected and why.
|
||||
* - Must provide a way to decline (user can still play with placeholder).
|
||||
* - Must link to the full privacy policy.
|
||||
*/
|
||||
|
||||
const {
|
||||
SCREEN_WIDTH,
|
||||
SCREEN_HEIGHT,
|
||||
} = require('../base/GameGlobal');
|
||||
|
||||
const { t } = require('../i18n/I18n');
|
||||
|
||||
// Colors
|
||||
const OVERLAY_BG = 'rgba(0, 0, 0, 0.75)';
|
||||
const DIALOG_BG = '#1e1e32';
|
||||
const DIALOG_BORDER = '#4a90d9';
|
||||
const TITLE_COLOR = '#FFD700';
|
||||
const TEXT_COLOR = '#CCCCCC';
|
||||
const LINK_COLOR = '#4a90d9';
|
||||
const AGREE_BG = '#4a90d9';
|
||||
const AGREE_TEXT = '#FFFFFF';
|
||||
const DECLINE_BG = 'rgba(255, 255, 255, 0.08)';
|
||||
const DECLINE_BORDER = '#666666';
|
||||
const DECLINE_TEXT = '#AAAAAA';
|
||||
|
||||
class PrivacyPopup {
|
||||
constructor() {
|
||||
/** @type {boolean} Whether the popup is currently visible. */
|
||||
this._active = false;
|
||||
|
||||
/** @type {Function|null} The resolve callback from onNeedPrivacyAuthorization. */
|
||||
this._resolveCallback = null;
|
||||
|
||||
/** @type {string} Which API triggered the privacy request. */
|
||||
this._referrer = '';
|
||||
|
||||
/** @type {object|null} Button hit-test rects (computed during render). */
|
||||
this._agreeBtn = null;
|
||||
this._declineBtn = null;
|
||||
this._policyLink = null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Public API
|
||||
// ============================================================
|
||||
|
||||
get active() {
|
||||
return this._active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the privacy popup.
|
||||
* @param {Function} resolve - The resolve callback from onNeedPrivacyAuthorization.
|
||||
* @param {object} eventInfo - The eventInfo from onNeedPrivacyAuthorization.
|
||||
*/
|
||||
show(resolve, eventInfo) {
|
||||
// Guard: if popup is already active, chain the new resolve callback
|
||||
// so that when the current popup resolves, both callbacks get called.
|
||||
// This prevents showing two popups at once.
|
||||
if (this._active && this._resolveCallback) {
|
||||
console.warn('[PrivacyPopup] Already active, chaining resolve callback. referrer:', this._referrer, ', new referrer:', (eventInfo && eventInfo.referrer) || '');
|
||||
const prevResolve = this._resolveCallback;
|
||||
this._resolveCallback = (result) => {
|
||||
try { prevResolve(result); } catch (e) { /* ignore */ }
|
||||
try { resolve(result); } catch (e) { /* ignore */ }
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
this._active = true;
|
||||
this._resolveCallback = resolve;
|
||||
this._referrer = (eventInfo && eventInfo.referrer) || '';
|
||||
console.log('[PrivacyPopup] Showing popup, referrer:', this._referrer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the popup (without resolving — used internally after resolve is called).
|
||||
*/
|
||||
hide() {
|
||||
this._active = false;
|
||||
this._resolveCallback = null;
|
||||
this._agreeBtn = null;
|
||||
this._declineBtn = null;
|
||||
this._policyLink = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch event. Returns true if the touch was consumed.
|
||||
* @param {string} eventType - 'touchstart' | 'touchend'
|
||||
* @param {object} e - The touch event object.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
handleTouch(eventType, e) {
|
||||
if (!this._active) return false;
|
||||
if (eventType !== 'touchend') return true; // Consume touchstart to prevent bleed-through
|
||||
|
||||
const touch = e.changedTouches && e.changedTouches[0];
|
||||
if (!touch) return true;
|
||||
|
||||
const tx = touch.clientX;
|
||||
const ty = touch.clientY;
|
||||
|
||||
// Agree button
|
||||
if (this._agreeBtn && this._hitTest(tx, ty, this._agreeBtn)) {
|
||||
console.log('[PrivacyPopup] User tapped AGREE');
|
||||
this._resolve('agree');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Decline button
|
||||
if (this._declineBtn && this._hitTest(tx, ty, this._declineBtn)) {
|
||||
console.log('[PrivacyPopup] User tapped DECLINE');
|
||||
this._resolve('disagree');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Privacy policy link
|
||||
if (this._policyLink && this._hitTest(tx, ty, this._policyLink)) {
|
||||
console.log('[PrivacyPopup] User tapped privacy policy link');
|
||||
this._openPrivacyPolicy();
|
||||
return true;
|
||||
}
|
||||
|
||||
return true; // Consume all touches while popup is active
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Render
|
||||
// ============================================================
|
||||
|
||||
render(ctx) {
|
||||
if (!this._active) return;
|
||||
|
||||
const cw = SCREEN_WIDTH;
|
||||
const ch = SCREEN_HEIGHT;
|
||||
const cx = cw / 2;
|
||||
const cy = ch / 2;
|
||||
|
||||
// --- Semi-transparent overlay ---
|
||||
ctx.fillStyle = OVERLAY_BG;
|
||||
ctx.fillRect(0, 0, cw, ch);
|
||||
|
||||
// --- Dialog box ---
|
||||
const dialogW = Math.min(cw * 0.88, 420);
|
||||
const dialogH = Math.min(ch * 0.78, 320);
|
||||
const dialogX = cx - dialogW / 2;
|
||||
const dialogY = cy - dialogH / 2;
|
||||
|
||||
// Shadow
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
|
||||
this._roundRect(ctx, dialogX + 4, dialogY + 4, dialogW, dialogH, 12);
|
||||
ctx.fill();
|
||||
|
||||
// Background
|
||||
ctx.fillStyle = DIALOG_BG;
|
||||
this._roundRect(ctx, dialogX, dialogY, dialogW, dialogH, 12);
|
||||
ctx.fill();
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = DIALOG_BORDER;
|
||||
ctx.lineWidth = 1.5;
|
||||
this._roundRect(ctx, dialogX, dialogY, dialogW, dialogH, 12);
|
||||
ctx.stroke();
|
||||
|
||||
// --- Shield icon (Canvas path) ---
|
||||
const iconY = dialogY + 32;
|
||||
const sw = 22; // shield width
|
||||
const sh = 26; // shield height
|
||||
const sx = cx - sw / 2;
|
||||
const sy = iconY - sh / 2;
|
||||
|
||||
// Shield body
|
||||
ctx.fillStyle = DIALOG_BORDER;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(sx, sy);
|
||||
ctx.lineTo(sx + sw, sy);
|
||||
ctx.lineTo(sx + sw, sy + sh * 0.55);
|
||||
ctx.quadraticCurveTo(sx + sw, sy + sh * 0.85, cx, sy + sh);
|
||||
ctx.quadraticCurveTo(sx, sy + sh * 0.85, sx, sy + sh * 0.55);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Checkmark inside shield
|
||||
ctx.strokeStyle = '#FFFFFF';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - 6, iconY - 1);
|
||||
ctx.lineTo(cx - 2, iconY + 4);
|
||||
ctx.lineTo(cx + 7, iconY - 5);
|
||||
ctx.stroke();
|
||||
|
||||
// --- Title ---
|
||||
const titleY = iconY + 30;
|
||||
ctx.fillStyle = TITLE_COLOR;
|
||||
ctx.font = 'bold 17px Arial';
|
||||
ctx.fillText(t('privacy.title'), cx, titleY);
|
||||
|
||||
// --- Body text ---
|
||||
const bodyY = titleY + 28;
|
||||
const bodyW = dialogW - 50;
|
||||
ctx.fillStyle = TEXT_COLOR;
|
||||
ctx.font = '13px Arial';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
const bodyLines = this._wrapText(ctx, t('privacy.body'), bodyW);
|
||||
for (let i = 0; i < bodyLines.length; i++) {
|
||||
ctx.fillText(bodyLines[i], dialogX + 25, bodyY + i * 20);
|
||||
}
|
||||
|
||||
// --- Privacy policy link ---
|
||||
const linkY = bodyY + bodyLines.length * 20 + 10;
|
||||
const linkText = t('privacy.policyLink');
|
||||
ctx.fillStyle = LINK_COLOR;
|
||||
ctx.font = 'bold 13px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(linkText, cx, linkY);
|
||||
|
||||
// Store link hit area
|
||||
const linkMetrics = ctx.measureText(linkText);
|
||||
this._policyLink = {
|
||||
x: cx - linkMetrics.width / 2 - 6,
|
||||
y: linkY - 10,
|
||||
w: linkMetrics.width + 12,
|
||||
h: 20,
|
||||
};
|
||||
|
||||
// Underline the link
|
||||
ctx.strokeStyle = LINK_COLOR;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(cx - linkMetrics.width / 2, linkY + 7);
|
||||
ctx.lineTo(cx + linkMetrics.width / 2, linkY + 7);
|
||||
ctx.stroke();
|
||||
|
||||
// --- Buttons ---
|
||||
const btnAreaY = linkY + 30;
|
||||
const btnW = Math.min((dialogW - 60) / 2, 150);
|
||||
const btnH = 38;
|
||||
const btnGap = 16;
|
||||
const agreeX = cx - btnW - btnGap / 2;
|
||||
const declineX = cx + btnGap / 2;
|
||||
|
||||
// Agree button
|
||||
ctx.fillStyle = AGREE_BG;
|
||||
this._roundRect(ctx, agreeX, btnAreaY, btnW, btnH, 8);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = AGREE_TEXT;
|
||||
ctx.font = 'bold 15px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t('privacy.agree'), agreeX + btnW / 2, btnAreaY + btnH / 2);
|
||||
this._agreeBtn = { x: agreeX, y: btnAreaY, w: btnW, h: btnH };
|
||||
|
||||
// Decline button
|
||||
ctx.fillStyle = DECLINE_BG;
|
||||
this._roundRect(ctx, declineX, btnAreaY, btnW, btnH, 8);
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = DECLINE_BORDER;
|
||||
ctx.lineWidth = 1;
|
||||
this._roundRect(ctx, declineX, btnAreaY, btnW, btnH, 8);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = DECLINE_TEXT;
|
||||
ctx.font = '14px Arial';
|
||||
ctx.fillText(t('privacy.decline'), declineX + btnW / 2, btnAreaY + btnH / 2);
|
||||
this._declineBtn = { x: declineX, y: btnAreaY, w: btnW, h: btnH };
|
||||
|
||||
// --- Footer hint ---
|
||||
const footerY = btnAreaY + btnH + 16;
|
||||
ctx.fillStyle = '#666666';
|
||||
ctx.font = '11px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(t('privacy.footer'), cx, footerY);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Private
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Resolve the privacy authorization request.
|
||||
* @param {'agree'|'disagree'} action
|
||||
*/
|
||||
_resolve(action) {
|
||||
if (!this._resolveCallback) {
|
||||
console.warn('[PrivacyPopup] No resolve callback to call');
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === 'agree') {
|
||||
console.log('[PrivacyPopup] Resolving with agree');
|
||||
this._resolveCallback({ event: 'agree' });
|
||||
} else {
|
||||
console.log('[PrivacyPopup] Resolving with disagree');
|
||||
// Disagree still needs to resolve, but the API will fail gracefully
|
||||
this._resolveCallback({ event: 'disagree' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[PrivacyPopup] resolve() threw:', e);
|
||||
}
|
||||
|
||||
this.hide();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the privacy policy document.
|
||||
* Uses wx.openPrivacyContract if available.
|
||||
*/
|
||||
_openPrivacyPolicy() {
|
||||
try {
|
||||
if (typeof wx !== 'undefined' && typeof wx.openPrivacyContract === 'function') {
|
||||
wx.openPrivacyContract({
|
||||
success: () => console.log('[PrivacyPopup] Privacy contract opened'),
|
||||
fail: (err) => console.warn('[PrivacyPopup] Failed to open privacy contract:', err),
|
||||
});
|
||||
} else {
|
||||
console.warn('[PrivacyPopup] wx.openPrivacyContract is not available');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PrivacyPopup] Error opening privacy contract:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple hit-test for rectangular area.
|
||||
*/
|
||||
_hitTest(tx, ty, rect) {
|
||||
return tx >= rect.x && tx <= rect.x + rect.w &&
|
||||
ty >= rect.y && ty <= rect.y + rect.h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text into lines that fit within maxWidth.
|
||||
*/
|
||||
_wrapText(ctx, text, maxWidth) {
|
||||
const lines = [];
|
||||
const paragraphs = text.split('\n');
|
||||
for (const para of paragraphs) {
|
||||
let line = '';
|
||||
for (let i = 0; i < para.length; i++) {
|
||||
const testLine = line + para[i];
|
||||
const metrics = ctx.measureText(testLine);
|
||||
if (metrics.width > maxWidth && line.length > 0) {
|
||||
lines.push(line);
|
||||
line = para[i];
|
||||
} else {
|
||||
line = testLine;
|
||||
}
|
||||
}
|
||||
if (line) lines.push(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a rounded rectangle path.
|
||||
*/
|
||||
_roundRect(ctx, x, y, w, h, r) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.lineTo(x + w - r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + r, r);
|
||||
ctx.lineTo(x + w, y + h - r);
|
||||
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
||||
ctx.lineTo(x + r, y + h);
|
||||
ctx.arcTo(x, y + h, x, y + h - r, r);
|
||||
ctx.lineTo(x, y + r);
|
||||
ctx.arcTo(x, y, x + r, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PrivacyPopup;
|
||||
Reference in New Issue
Block a user