1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-05 06:35:57 +02:00

feat(frontend): 自分のプロフィールページの二次元コード(QRコード)を表示し、他の人のコードを読み取りするページを追加 (#16456)

* wip (qr.show.vue)

* added to navbar

* qr.show.vue

* fix

* added to navbar

* fix size

* 🎨

* 🎨

* fix div warn

* fix

* use * 0.25

* fix??

* fix lint

* clean up

* ???

* ?

* fix

* 🎨

* 🎨

* refactor

* 🎨

* 🎨

* :ar:t

* 🎨

* iphone flip

* no lazy import

* 🎨

* 🎨

* 🎨

* ユーザー全部flipでいいや

* ✌️

* fix

* fix

* fix lint

* 🎨

* fix type

* fix: local user profile url cannot be resolved with ap/show

* fix: local user url with hostname could not be resolved

* chore: use common utility for checking self host

* wip

* 🎨

* 🎨

* fix imports

* fix

* fix

* fix

* 🎨

* fix...

* set spacer-w

* ✌️

* 全画面でQRを読むように

* fix

* 🎨

* modify navbar.ts

* start/stop on vue activation

* display raw content read from qr

* 端末のQRをスキャンするボタンを追加

* chore

* やっぱりmfmを先に表示する

* 🎨

* fix 18n

* QRの内容は/users/:userIdにする

* add spdx

* use cqh

* `defineProps` is a compiler macro and no longer needs to be imported.

* use MkUserName

* 🎨

* 🎨

* refactor

* clean up

* refactor

* 🎨

* Update qr.show.vue

* Misskeyロゴにdrop-shadowを追加

* clean up: do not use empty css

* fix os.select usage

* Update qr.vue

* Update qr.show.vue

* Update qr.show.vue

* Update get-user-menu.ts

* ✌️

* Update show.ts

* Update ja-JP.yml

* watermark

* Update CHANGELOG.md

* Update qr.read.vue

* Update qr.read.vue

* wip

* Update MkWatermarkEditorDialog.Layer.vue

---------

Co-authored-by: anatawa12 <anatawa12@icloud.com>
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
This commit is contained in:
tamaina
2025-09-19 21:02:30 +09:00
committed by GitHub
parent 97adf6f2cc
commit 42b2aea533
23 changed files with 1122 additions and 62 deletions

View File

@@ -215,6 +215,16 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
});
}
if ($i && meId === user.id) {
menuItems.push({
icon: 'ti ti-qrcode',
text: i18n.ts.qr,
action: () => {
router.push('/qr');
},
});
}
if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) {
menuItems.push({
icon: 'ti ti-search',

View File

@@ -3,8 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import QRCodeStyling from 'qr-code-styling';
import { url, host } from '@@/js/config.js';
import { getProxiedImageUrl } from '../media-proxy.js';
import { initShaderProgram } from '../webgl.js';
import { ensureSignin } from '@/i.js';
export type ImageEffectorRGB = [r: number, g: number, b: number];
@@ -48,6 +51,7 @@ interface AlignParamDef extends CommonParamDef {
default: {
x: 'left' | 'center' | 'right';
y: 'top' | 'center' | 'bottom';
margin?: number;
};
};
@@ -58,7 +62,13 @@ interface SeedParamDef extends CommonParamDef {
interface TextureParamDef extends CommonParamDef {
type: 'texture';
default: { type: 'text'; text: string | null; } | { type: 'url'; url: string | null; } | null;
default: {
type: 'text'; text: string | null;
} | {
type: 'url'; url: string | null;
} | {
type: 'qr'; data: string | null;
} | null;
};
interface ColorParamDef extends CommonParamDef {
@@ -324,7 +334,11 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
if (_DEV_) console.log(`Baking texture of <${textureKey}>...`);
const texture = v.type === 'text' ? await createTextureFromText(this.gl, v.text) : v.type === 'url' ? await createTextureFromUrl(this.gl, v.url) : null;
const texture =
v.type === 'text' ? await createTextureFromText(this.gl, v.text) :
v.type === 'url' ? await createTextureFromUrl(this.gl, v.url) :
v.type === 'qr' ? await createTextureFromQr(this.gl, { data: v.data }) :
null;
if (texture == null) continue;
this.paramTextures.set(textureKey, texture);
@@ -352,7 +366,12 @@ export class ImageEffector<IEX extends ReadonlyArray<ImageEffectorFx<any, any, a
private getTextureKeyForParam(v: ParamTypeToPrimitive['texture']) {
if (v == null) return '';
return v.type === 'text' ? `text:${v.text}` : v.type === 'url' ? `url:${v.url}` : '';
return (
v.type === 'text' ? `text:${v.text}` :
v.type === 'url' ? `url:${v.url}` :
v.type === 'qr' ? `qr:${v.data}` :
''
);
}
/*
@@ -467,3 +486,53 @@ async function createTextureFromText(gl: WebGL2RenderingContext, text: string |
return info;
}
async function createTextureFromQr(gl: WebGL2RenderingContext, options: { data: string | null }, resolution = 512): Promise<{ texture: WebGLTexture, width: number, height: number } | null> {
const $i = ensureSignin();
const qrCodeInstance = new QRCodeStyling({
width: resolution,
height: resolution,
margin: 42,
type: 'canvas',
data: options.data == null || options.data === '' ? `${url}/users/${$i.id}` : options.data,
image: $i.avatarUrl,
qrOptions: {
typeNumber: 0,
mode: 'Byte',
errorCorrectionLevel: 'H',
},
imageOptions: {
hideBackgroundDots: true,
imageSize: 0.3,
margin: 16,
crossOrigin: 'anonymous',
},
dotsOptions: {
type: 'dots',
},
cornersDotOptions: {
type: 'dot',
},
cornersSquareOptions: {
type: 'extra-rounded',
},
});
const blob = await qrCodeInstance.getRawData('png') as Blob | null;
if (blob == null) return null;
const image = await window.createImageBitmap(blob);
const texture = createTexture(gl);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, resolution, resolution, 0, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.bindTexture(gl.TEXTURE_2D, null);
return {
texture,
width: resolution,
height: resolution,
};
}

View File

@@ -23,6 +23,7 @@ uniform float u_opacity;
uniform bool u_repeat;
uniform int u_alignX; // 0: left, 1: center, 2: right
uniform int u_alignY; // 0: top, 1: center, 2: bottom
uniform float u_alignMargin;
uniform int u_fitMode; // 0: contain, 1: cover
out vec4 out_color;
@@ -51,6 +52,9 @@ void main() {
float x_offset = u_alignX == 0 ? x_scale / 2.0 : u_alignX == 2 ? 1.0 - (x_scale / 2.0) : 0.5;
float y_offset = u_alignY == 0 ? y_scale / 2.0 : u_alignY == 2 ? 1.0 - (y_scale / 2.0) : 0.5;
x_offset += (u_alignX == 0 ? 1.0 : u_alignX == 2 ? -1.0 : 0.0) * u_alignMargin;
y_offset += (u_alignY == 0 ? 1.0 : u_alignY == 2 ? -1.0 : 0.0) * u_alignMargin;
float angle = -(u_angle * PI);
vec2 center = vec2(x_offset, y_offset);
//vec2 centeredUv = (in_uv - center) * vec2(in_x_ratio, in_y_ratio);
@@ -86,7 +90,7 @@ export const FX_watermarkPlacement = defineImageEffectorFx({
id: 'watermarkPlacement',
name: '(internal)',
shader,
uniforms: ['texture_watermark', 'resolution_watermark', 'scale', 'angle', 'opacity', 'repeat', 'alignX', 'alignY', 'fitMode'] as const,
uniforms: ['texture_watermark', 'resolution_watermark', 'scale', 'angle', 'opacity', 'repeat', 'alignX', 'alignY', 'alignMargin', 'fitMode'] as const,
params: {
cover: {
type: 'boolean',
@@ -112,7 +116,7 @@ export const FX_watermarkPlacement = defineImageEffectorFx({
},
align: {
type: 'align',
default: { x: 'right', y: 'bottom' },
default: { x: 'right', y: 'bottom', margin: 0 },
},
opacity: {
type: 'number',
@@ -143,6 +147,7 @@ export const FX_watermarkPlacement = defineImageEffectorFx({
gl.uniform1i(u.repeat, params.repeat ? 1 : 0);
gl.uniform1i(u.alignX, params.align.x === 'left' ? 0 : params.align.x === 'right' ? 2 : 1);
gl.uniform1i(u.alignY, params.align.y === 'top' ? 0 : params.align.y === 'bottom' ? 2 : 1);
gl.uniform1f(u.alignMargin, params.align.margin ?? 0);
gl.uniform1i(u.fitMode, params.cover ? 1 : 0);
},
});

View File

@@ -3,11 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ImageEffectorFx, ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
import { FX_watermarkPlacement } from '@/utility/image-effector/fxs/watermarkPlacement.js';
import { FX_stripe } from '@/utility/image-effector/fxs/stripe.js';
import { FX_polkadot } from '@/utility/image-effector/fxs/polkadot.js';
import { FX_checker } from '@/utility/image-effector/fxs/checker.js';
import type { ImageEffectorFx, ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.js';
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
const WATERMARK_FXS = [
@@ -17,6 +17,8 @@ const WATERMARK_FXS = [
FX_checker,
] as const satisfies ImageEffectorFx<string, any>[];
type Align = { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom'; margin?: number; };
export type WatermarkPreset = {
id: string;
name: string;
@@ -27,7 +29,7 @@ export type WatermarkPreset = {
repeat: boolean;
scale: number;
angle: number;
align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom' };
align: Align;
opacity: number;
} | {
id: string;
@@ -38,7 +40,14 @@ export type WatermarkPreset = {
repeat: boolean;
scale: number;
angle: number;
align: { x: 'left' | 'center' | 'right'; y: 'top' | 'center' | 'bottom' };
align: Align;
opacity: number;
} | {
id: string;
type: 'qr';
data: string;
scale: number;
align: Align;
opacity: number;
} | {
id: string;
@@ -125,6 +134,23 @@ export class WatermarkRenderer {
},
},
};
} else if (layer.type === 'qr') {
return {
fxId: 'watermarkPlacement',
id: layer.id,
params: {
repeat: false,
scale: layer.scale,
align: layer.align,
angle: 0,
opacity: layer.opacity,
cover: false,
watermark: {
type: 'qr',
data: layer.data,
},
},
};
} else if (layer.type === 'stripe') {
return {
fxId: 'stripe',
@@ -164,7 +190,7 @@ export class WatermarkRenderer {
},
};
} else {
throw new Error(`Unknown layer type`);
throw new Error(`Unrecognized layer type: ${(layer as any).type}`);
}
});
}