Compare commits
39 Commits
c4c7f817c3
...
cafd992c24
Author | SHA1 | Date |
---|---|---|
|
cafd992c24 | |
|
b8ae7edcec | |
|
e24233c1c7 | |
|
225154d76d | |
|
c5f9c0ce5c | |
|
cce302ae4f | |
|
e0d210e15b | |
|
0b7634b126 | |
|
d1446d195a | |
|
218070eb13 | |
|
0f8c068e84 | |
|
69d66b89f2 | |
|
211365de64 | |
|
966127c63e | |
|
54800971eb | |
|
13d5c6d2b2 | |
|
2cff00eedd | |
|
3fc2261041 | |
|
18d66c0233 | |
|
2f52c20150 | |
|
9d70c9ad78 | |
|
42b2aea533 | |
|
97adf6f2cc | |
|
93ff209c51 | |
|
5fe08d0bbb | |
|
8c413d01e6 | |
|
b231da7c7c | |
|
df3e44f62e | |
|
e504560477 | |
|
bcb2073715 | |
|
6a80c23a50 | |
|
2621f468ff | |
|
1003caecd9 | |
|
d432a26d5b | |
|
ac4fb4fc3d | |
|
394f6c09cb | |
|
bd68b09fcf | |
|
896352bdfd | |
|
10dbd8f07f |
|
@ -105,6 +105,16 @@ port: 3000
|
|||
# socket: /path/to/misskey.sock
|
||||
# chmodSocket: '777'
|
||||
|
||||
# Proxy trust settings
|
||||
#
|
||||
# Changes how the server interpret the origin IP of the request.
|
||||
#
|
||||
# Any format supported by Fastify is accepted.
|
||||
# Default: trust all proxies (i.e. trustProxy: true)
|
||||
# See: https://fastify.dev/docs/latest/reference/server/#trustproxy
|
||||
#
|
||||
# trustProxy: 1
|
||||
|
||||
# ┌──────────────────────────┐
|
||||
#───┘ PostgreSQL configuration └────────────────────────────────
|
||||
|
||||
|
|
23
CHANGELOG.md
23
CHANGELOG.md
|
@ -1,14 +1,28 @@
|
|||
## Unreleased
|
||||
## 2025.9.1
|
||||
|
||||
### NOTE
|
||||
- pnpm 10.16.0 が必要です
|
||||
|
||||
### General
|
||||
-
|
||||
- Feat: 予約投稿ができるようになりました
|
||||
- デフォルトで作成可能数は1になっています。適宜ロールのポリシーで設定を行ってください。
|
||||
- Enhance: 広告ごとにセンシティブフラグを設定できるようになりました
|
||||
|
||||
### Client
|
||||
- Feat: アカウントのQRコードを表示・読み取りできるようになりました
|
||||
- Feat: 動画を圧縮してアップロードできるようになりました
|
||||
- Enhance: チャットの日本語名称がダイレクトメッセージに戻るとともに、ベータ版機能ではなくなりました
|
||||
- Enhance: 画像編集にマスクエフェクト(塗りつぶし、ぼかし、モザイク)を追加
|
||||
- Enhance: 画像編集の集中線エフェクトを強化
|
||||
- Enhance: ウォーターマークにアカウントのQRコードを追加できるように
|
||||
- Enhance: テーマをドラッグ&ドロップできるように
|
||||
- Enhance: 絵文字ピッカーのサイズをより大きくできるように
|
||||
- Enhance: 時刻計算のための基準値を一か所で管理するようにし、パフォーマンスを向上
|
||||
- Fix: iOSで、デバイスがダークモードだと初回読み込み時にエラーになる問題を修正
|
||||
- Fix: アクティビティウィジェットのグラフモードが動作しない問題を修正
|
||||
|
||||
### Server
|
||||
-
|
||||
|
||||
- Enhance: ユーザーIPを確実に取得できるために設定ファイルにFastifyOptions.trustProxyを追加しました
|
||||
|
||||
## 2025.9.0
|
||||
|
||||
|
@ -759,6 +773,7 @@
|
|||
- Fix: ファイルの詳細ページのファイルの説明で改行が正しく表示されない問題を修正
|
||||
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/commit/bde6bb0bd2e8b0d027e724d2acdb8ae0585a8110)
|
||||
- Fix: 一部画面のページネーションが動作しにくくなっていたのを修正 ( #12766 , #11449 )
|
||||
- Fix: MFMが機能すべき箇所で機能していないところがある問題を修正
|
||||
|
||||
### Server
|
||||
- Feat: Misskey® Reactions Boost Technology™ (RBT)により、リアクションの作成負荷を低減することが可能に
|
||||
|
|
|
@ -0,0 +1,232 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<canvas ref="canvasEl" style="display: block; width: 100%; height: 100%; pointer-events: none;"></canvas>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
import { initShaderProgram } from '@/utility/webgl.js';
|
||||
|
||||
const VERTEX_SHADER = `#version 300 es
|
||||
in vec2 position;
|
||||
out vec2 in_uv;
|
||||
|
||||
void main() {
|
||||
in_uv = (position + 1.0) / 2.0;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAGMENT_SHADER = `#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
const float PI = 3.141592653589793;
|
||||
const float TWO_PI = 6.283185307179586;
|
||||
const float HALF_PI = 1.5707963267948966;
|
||||
|
||||
in vec2 in_uv;
|
||||
uniform vec2 in_resolution;
|
||||
uniform float u_scale;
|
||||
uniform float u_time;
|
||||
uniform float u_seed;
|
||||
uniform float u_angle;
|
||||
uniform float u_radius;
|
||||
uniform vec3 u_color;
|
||||
uniform vec2 u_ripplePositions[16];
|
||||
uniform float u_rippleRadiuses[16];
|
||||
out vec4 out_color;
|
||||
|
||||
float getRipple(vec2 uv) {
|
||||
float strength = 0.0;
|
||||
float thickness = 0.05;
|
||||
for (int i = 0; i < 16; i++) {
|
||||
if (u_rippleRadiuses[i] <= 0.0) continue;
|
||||
|
||||
float d = distance(uv, u_ripplePositions[i]);
|
||||
|
||||
// フチ
|
||||
if (d < u_rippleRadiuses[i] + thickness && d > u_rippleRadiuses[i] - thickness) {
|
||||
float gradate = abs(d - u_rippleRadiuses[i] + thickness) / thickness;
|
||||
strength += (1.0 - u_rippleRadiuses[i]) * gradate;
|
||||
}
|
||||
|
||||
// 内側
|
||||
if (d < u_rippleRadiuses[i] + thickness) {
|
||||
strength += 0.25 * (1.0 - u_rippleRadiuses[i]);
|
||||
}
|
||||
}
|
||||
return strength;
|
||||
}
|
||||
|
||||
void main() {
|
||||
float x_ratio = min(in_resolution.x / in_resolution.y, 1.0);
|
||||
float y_ratio = min(in_resolution.y / in_resolution.x, 1.0);
|
||||
|
||||
float angle = -(u_angle * PI);
|
||||
vec2 centeredUv = (in_uv - vec2(0.5, 0.5)) * vec2(x_ratio, y_ratio);
|
||||
vec2 rotatedUV = vec2(
|
||||
centeredUv.x * cos(angle) - centeredUv.y * sin(angle),
|
||||
centeredUv.x * sin(angle) + centeredUv.y * cos(angle)
|
||||
);
|
||||
vec2 uv = rotatedUV;
|
||||
|
||||
float time = u_time * 0.00025;
|
||||
|
||||
float size = 1.0 / u_scale;
|
||||
float size_half = size / 2.0;
|
||||
float modX = mod(uv.x, size);
|
||||
float modY = mod(uv.y, size);
|
||||
|
||||
vec2 pixelated_uv = vec2(
|
||||
(size * (floor((uv.x - 0.5 - size) / size) + 0.5)),
|
||||
(size * (floor((uv.y - 0.5 - size) / size) + 0.5))
|
||||
) + vec2(0.5 + size, 0.5 + size);
|
||||
|
||||
float strength = getRipple(pixelated_uv);
|
||||
|
||||
float opacity = min(max(strength, 0.0), 1.0);
|
||||
|
||||
float threshold = ((u_radius / 2.0) / u_scale);
|
||||
if (length(vec2(modX - size_half, modY - size_half)) < threshold) {
|
||||
out_color = vec4(u_color.r, u_color.g, u_color.b, opacity);
|
||||
//out_color = vec4(1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
// debug
|
||||
//float a = min(max(getRipple(uv), 0.0), 1.0);
|
||||
//out_color = vec4(u_color.r, u_color.g, u_color.b, (opacity + a) / 2.0);
|
||||
|
||||
out_color = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
scale?: number;
|
||||
}>(), {
|
||||
scale: 48,
|
||||
});
|
||||
|
||||
let handle: ReturnType<typeof window['requestAnimationFrame']> | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = canvasEl.value!;
|
||||
let width = canvas.offsetWidth;
|
||||
let height = canvas.offsetHeight;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const maybeGl = canvas.getContext('webgl2', { preserveDrawingBuffer: false, alpha: true, premultipliedAlpha: false, antialias: true });
|
||||
if (maybeGl == null) return;
|
||||
|
||||
const gl = maybeGl;
|
||||
|
||||
const VERTICES = new Float32Array([-1, -1, -1, 1, 1, 1, -1, -1, 1, 1, 1, -1]);
|
||||
const vertexBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
|
||||
|
||||
//gl.clearColor(0.0, 0.0, 0.0, 0.0);
|
||||
//gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
const shaderProgram = initShaderProgram(gl, VERTEX_SHADER, FRAGMENT_SHADER);
|
||||
|
||||
gl.useProgram(shaderProgram);
|
||||
|
||||
const positionLocation = gl.getAttribLocation(shaderProgram, 'position');
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.enableVertexAttribArray(positionLocation);
|
||||
|
||||
const in_resolution = gl.getUniformLocation(shaderProgram, 'in_resolution');
|
||||
gl.uniform2fv(in_resolution, [canvas.width, canvas.height]);
|
||||
|
||||
const u_time = gl.getUniformLocation(shaderProgram, 'u_time');
|
||||
const u_seed = gl.getUniformLocation(shaderProgram, 'u_seed');
|
||||
const u_scale = gl.getUniformLocation(shaderProgram, 'u_scale');
|
||||
const u_angle = gl.getUniformLocation(shaderProgram, 'u_angle');
|
||||
const u_radius = gl.getUniformLocation(shaderProgram, 'u_radius');
|
||||
const u_color = gl.getUniformLocation(shaderProgram, 'u_color');
|
||||
gl.uniform1f(u_seed, Math.random() * 1000);
|
||||
gl.uniform1f(u_scale, props.scale);
|
||||
gl.uniform1f(u_angle, 0.0);
|
||||
gl.uniform1f(u_radius, 0.15);
|
||||
gl.uniform3fv(u_color, [0.5, 1.0, 0]);
|
||||
|
||||
if (isChromatic()) {
|
||||
gl.uniform1f(u_time, 0);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
} else {
|
||||
let ripples = [] as { position: [number, number]; startTime: number; }[];
|
||||
const LIFE_TIME = 1000 * 4;
|
||||
|
||||
function render(timeStamp: number) {
|
||||
let sizeChanged = false;
|
||||
if (Math.abs(height - canvas.offsetHeight) > 2) {
|
||||
height = canvas.offsetHeight;
|
||||
canvas.height = height;
|
||||
sizeChanged = true;
|
||||
}
|
||||
if (Math.abs(width - canvas.offsetWidth) > 2) {
|
||||
width = canvas.offsetWidth;
|
||||
canvas.width = width;
|
||||
sizeChanged = true;
|
||||
}
|
||||
if (sizeChanged && gl) {
|
||||
gl.uniform2fv(in_resolution, [width, height]);
|
||||
gl.viewport(0, 0, width, height);
|
||||
}
|
||||
|
||||
gl.uniform1f(u_time, timeStamp);
|
||||
|
||||
if (Math.random() < 0.01 && ripples.length < 16) {
|
||||
ripples.push({ position: [(Math.random() * 2) - 1, (Math.random() * 2) - 1], startTime: timeStamp });
|
||||
}
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const o = gl.getUniformLocation(shaderProgram, `u_ripplePositions[${i.toString()}]`);
|
||||
const r = gl.getUniformLocation(shaderProgram, `u_rippleRadiuses[${i.toString()}]`);
|
||||
const ripple = ripples[i];
|
||||
if (ripple == null) {
|
||||
gl.uniform2f(o, 0, 0);
|
||||
gl.uniform1f(r, 0.0);
|
||||
continue;
|
||||
}
|
||||
|
||||
const delta = timeStamp - ripple.startTime;
|
||||
|
||||
gl.uniform2f(o, ripple.position[0], ripple.position[1]);
|
||||
gl.uniform1f(r, delta / LIFE_TIME);
|
||||
}
|
||||
|
||||
ripples = ripples.filter(r => (timeStamp - r.startTime) < LIFE_TIME);
|
||||
if (ripples.length === 0) {
|
||||
ripples.push({ position: [(Math.random() * 2) - 1, (Math.random() * 2) - 1], startTime: timeStamp });
|
||||
}
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
|
||||
handle = window.requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
handle = window.requestAnimationFrame(render);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (handle) {
|
||||
window.cancelAnimationFrame(handle);
|
||||
}
|
||||
|
||||
// TODO: WebGLリソースの解放
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
</style>
|
|
@ -0,0 +1,190 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<canvas ref="canvasEl" style="display: block; width: 100%; height: 100%; pointer-events: none;"></canvas>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
import { GLSL_LIB_SNOISE, initShaderProgram } from '@/utility/webgl.js';
|
||||
|
||||
const VERTEX_SHADER = `#version 300 es
|
||||
in vec2 position;
|
||||
out vec2 in_uv;
|
||||
|
||||
void main() {
|
||||
in_uv = (position + 1.0) / 2.0;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAGMENT_SHADER = `#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
const float PI = 3.141592653589793;
|
||||
const float TWO_PI = 6.283185307179586;
|
||||
const float HALF_PI = 1.5707963267948966;
|
||||
|
||||
${GLSL_LIB_SNOISE}
|
||||
|
||||
in vec2 in_uv;
|
||||
uniform vec2 in_resolution;
|
||||
uniform float u_scale;
|
||||
uniform float u_time;
|
||||
uniform float u_seed;
|
||||
uniform float u_angle;
|
||||
uniform float u_radius;
|
||||
uniform vec3 u_color;
|
||||
out vec4 out_color;
|
||||
|
||||
void main() {
|
||||
float x_ratio = min(in_resolution.x / in_resolution.y, 1.0);
|
||||
float y_ratio = min(in_resolution.y / in_resolution.x, 1.0);
|
||||
|
||||
float size = 1.0 / u_scale;
|
||||
float size_half = size / 2.0;
|
||||
|
||||
float angle = -(u_angle * PI);
|
||||
vec2 centeredUv = (in_uv - vec2(0.5, 0.5)) * vec2(x_ratio, y_ratio);
|
||||
vec2 rotatedUV = vec2(
|
||||
centeredUv.x * cos(angle) - centeredUv.y * sin(angle),
|
||||
centeredUv.x * sin(angle) + centeredUv.y * cos(angle)
|
||||
);
|
||||
vec2 uv = rotatedUV;
|
||||
|
||||
float modX = mod(uv.x, size);
|
||||
float modY = mod(uv.y, size);
|
||||
|
||||
vec2 pixelated_uv = vec2(
|
||||
(size * (floor((uv.x - 0.5 - size) / size) + 0.5)),
|
||||
(size * (floor((uv.y - 0.5 - size) / size) + 0.5))
|
||||
) + vec2(0.5 + size, 0.5 + size);
|
||||
|
||||
float time = u_time * 0.00025;
|
||||
|
||||
float noiseAScale = 1.0;
|
||||
float noiseAX = (pixelated_uv.x + u_seed) * (u_scale / noiseAScale);
|
||||
float noiseAY = (pixelated_uv.y + u_seed) * (u_scale / noiseAScale);
|
||||
float noiseA = snoise(vec3(noiseAX, noiseAY, time * 2.0));
|
||||
|
||||
float noiseBScale = 32.0;
|
||||
float noiseBX = (pixelated_uv.x + u_seed) * (u_scale / noiseBScale);
|
||||
float noiseBY = (pixelated_uv.y + u_seed) * (u_scale / noiseBScale);
|
||||
float noiseB = snoise(vec3(noiseBX, noiseBY, time));
|
||||
|
||||
float strength = 0.0;
|
||||
strength += noiseA * 0.2;
|
||||
strength += noiseB * 0.8;
|
||||
|
||||
float opacity = min(max(strength, 0.0), 1.0);
|
||||
|
||||
float threshold = ((u_radius / 2.0) / u_scale);
|
||||
if (length(vec2(modX - size_half, modY - size_half)) < threshold) {
|
||||
out_color = vec4(u_color.r, u_color.g, u_color.b, opacity);
|
||||
return;
|
||||
}
|
||||
|
||||
out_color = vec4(0.0, 0.0, 0.0, 0.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
scale?: number;
|
||||
}>(), {
|
||||
scale: 48,
|
||||
});
|
||||
|
||||
let handle: ReturnType<typeof window['requestAnimationFrame']> | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = canvasEl.value!;
|
||||
let width = canvas.offsetWidth;
|
||||
let height = canvas.offsetHeight;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const maybeGl = canvas.getContext('webgl2', { preserveDrawingBuffer: false, alpha: true, premultipliedAlpha: false, antialias: true });
|
||||
if (maybeGl == null) return;
|
||||
|
||||
const gl = maybeGl;
|
||||
|
||||
const VERTICES = new Float32Array([-1, -1, -1, 1, 1, 1, -1, -1, 1, 1, 1, -1]);
|
||||
const vertexBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, VERTICES, gl.STATIC_DRAW);
|
||||
|
||||
//gl.clearColor(0.0, 0.0, 0.0, 0.0);
|
||||
//gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
|
||||
const shaderProgram = initShaderProgram(gl, VERTEX_SHADER, FRAGMENT_SHADER);
|
||||
|
||||
gl.useProgram(shaderProgram);
|
||||
|
||||
const positionLocation = gl.getAttribLocation(shaderProgram, 'position');
|
||||
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
|
||||
gl.enableVertexAttribArray(positionLocation);
|
||||
|
||||
const in_resolution = gl.getUniformLocation(shaderProgram, 'in_resolution');
|
||||
gl.uniform2fv(in_resolution, [canvas.width, canvas.height]);
|
||||
|
||||
const u_time = gl.getUniformLocation(shaderProgram, 'u_time');
|
||||
const u_seed = gl.getUniformLocation(shaderProgram, 'u_seed');
|
||||
const u_scale = gl.getUniformLocation(shaderProgram, 'u_scale');
|
||||
const u_angle = gl.getUniformLocation(shaderProgram, 'u_angle');
|
||||
const u_radius = gl.getUniformLocation(shaderProgram, 'u_radius');
|
||||
const u_color = gl.getUniformLocation(shaderProgram, 'u_color');
|
||||
gl.uniform1f(u_seed, Math.random() * 1000);
|
||||
gl.uniform1f(u_scale, props.scale);
|
||||
gl.uniform1f(u_angle, 0.0);
|
||||
gl.uniform1f(u_radius, 0.15);
|
||||
gl.uniform3fv(u_color, [0.5, 1.0, 0]);
|
||||
|
||||
if (isChromatic()) {
|
||||
gl.uniform1f(u_time, 0);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
} else {
|
||||
function render(timeStamp: number) {
|
||||
let sizeChanged = false;
|
||||
if (Math.abs(height - canvas.offsetHeight) > 2) {
|
||||
height = canvas.offsetHeight;
|
||||
canvas.height = height;
|
||||
sizeChanged = true;
|
||||
}
|
||||
if (Math.abs(width - canvas.offsetWidth) > 2) {
|
||||
width = canvas.offsetWidth;
|
||||
canvas.width = width;
|
||||
sizeChanged = true;
|
||||
}
|
||||
if (sizeChanged && gl) {
|
||||
gl.uniform2fv(in_resolution, [width, height]);
|
||||
gl.viewport(0, 0, width, height);
|
||||
}
|
||||
|
||||
gl.uniform1f(u_time, timeStamp);
|
||||
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
|
||||
handle = window.requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
handle = window.requestAnimationFrame(render);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (handle) {
|
||||
window.cancelAnimationFrame(handle);
|
||||
}
|
||||
|
||||
// TODO: WebGLリソースの解放
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
</style>
|
|
@ -3194,6 +3194,7 @@ _imageEffector:
|
|||
mirror: "Mirror"
|
||||
invert: "Invert Colors"
|
||||
grayscale: "Grayscale"
|
||||
blur: "Blur"
|
||||
colorAdjust: "Color Correction"
|
||||
colorClamp: "Color Compression"
|
||||
colorClampAdvanced: "Color Compression (Advanced)"
|
||||
|
@ -3209,6 +3210,8 @@ _imageEffector:
|
|||
angle: "Angle"
|
||||
scale: "Size"
|
||||
size: "Size"
|
||||
radius: "Radius"
|
||||
samples: "Samples"
|
||||
color: "Color"
|
||||
opacity: "Opacity"
|
||||
normalize: "Normalize"
|
||||
|
|
|
@ -1030,6 +1030,10 @@ export interface Locale extends ILocale {
|
|||
* 処理中
|
||||
*/
|
||||
"processing": string;
|
||||
/**
|
||||
* 準備中
|
||||
*/
|
||||
"preprocessing": string;
|
||||
/**
|
||||
* プレビュー
|
||||
*/
|
||||
|
@ -1227,7 +1231,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"noMoreHistory": string;
|
||||
/**
|
||||
* チャットを始める
|
||||
* メッセージを送る
|
||||
*/
|
||||
"startChat": string;
|
||||
/**
|
||||
|
@ -1927,7 +1931,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"markAsReadAllUnreadNotes": string;
|
||||
/**
|
||||
* すべてのチャットを既読にする
|
||||
* すべてのダイレクトメッセージを既読にする
|
||||
*/
|
||||
"markAsReadAllTalkMessages": string;
|
||||
/**
|
||||
|
@ -5282,6 +5286,10 @@ export interface Locale extends ILocale {
|
|||
* 下書き
|
||||
*/
|
||||
"draft": string;
|
||||
/**
|
||||
* 下書きと予約投稿
|
||||
*/
|
||||
"draftsAndScheduledNotes": string;
|
||||
/**
|
||||
* リアクションする際に確認する
|
||||
*/
|
||||
|
@ -5390,6 +5398,14 @@ export interface Locale extends ILocale {
|
|||
* チャット
|
||||
*/
|
||||
"chat": string;
|
||||
/**
|
||||
* ダイレクトメッセージ
|
||||
*/
|
||||
"directMessage": string;
|
||||
/**
|
||||
* メッセージ
|
||||
*/
|
||||
"directMessage_short": string;
|
||||
/**
|
||||
* 旧設定情報を移行
|
||||
*/
|
||||
|
@ -5501,6 +5517,14 @@ export interface Locale extends ILocale {
|
|||
* 低くすると画質を保てますが、ファイルサイズは増加します。<br>高くするとファイルサイズを減らせますが、画質は低下します。
|
||||
*/
|
||||
"defaultImageCompressionLevel_description": string;
|
||||
/**
|
||||
* デフォルトの圧縮度
|
||||
*/
|
||||
"defaultCompressionLevel": string;
|
||||
/**
|
||||
* 低くすると品質を保てますが、ファイルサイズは増加します。<br>高くするとファイルサイズを減らせますが、品質は低下します。
|
||||
*/
|
||||
"defaultCompressionLevel_description": string;
|
||||
/**
|
||||
* 分
|
||||
*/
|
||||
|
@ -5529,6 +5553,60 @@ export interface Locale extends ILocale {
|
|||
* ベータ版の検証にご協力いただきありがとうございます!
|
||||
*/
|
||||
"thankYouForTestingBeta": string;
|
||||
/**
|
||||
* ユーザー指定ノートを作成
|
||||
*/
|
||||
"createUserSpecifiedNote": string;
|
||||
/**
|
||||
* 投稿を予約
|
||||
*/
|
||||
"schedulePost": string;
|
||||
/**
|
||||
* {x}に投稿を予約します
|
||||
*/
|
||||
"scheduleToPostOnX": ParameterizedString<"x">;
|
||||
/**
|
||||
* {x}に投稿が予約されています
|
||||
*/
|
||||
"scheduledToPostOnX": ParameterizedString<"x">;
|
||||
/**
|
||||
* 予約
|
||||
*/
|
||||
"schedule": string;
|
||||
/**
|
||||
* 予約
|
||||
*/
|
||||
"scheduled": string;
|
||||
"_compression": {
|
||||
"_quality": {
|
||||
/**
|
||||
* 高品質
|
||||
*/
|
||||
"high": string;
|
||||
/**
|
||||
* 中品質
|
||||
*/
|
||||
"medium": string;
|
||||
/**
|
||||
* 低品質
|
||||
*/
|
||||
"low": string;
|
||||
};
|
||||
"_size": {
|
||||
/**
|
||||
* サイズ大
|
||||
*/
|
||||
"large": string;
|
||||
/**
|
||||
* サイズ中
|
||||
*/
|
||||
"medium": string;
|
||||
/**
|
||||
* サイズ小
|
||||
*/
|
||||
"small": string;
|
||||
};
|
||||
};
|
||||
"_order": {
|
||||
/**
|
||||
* 新しい順
|
||||
|
@ -5540,6 +5618,10 @@ export interface Locale extends ILocale {
|
|||
"oldest": string;
|
||||
};
|
||||
"_chat": {
|
||||
/**
|
||||
* メッセージ
|
||||
*/
|
||||
"messages": string;
|
||||
/**
|
||||
* まだメッセージはありません
|
||||
*/
|
||||
|
@ -5549,36 +5631,36 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"newMessage": string;
|
||||
/**
|
||||
* 個人チャット
|
||||
* 個別
|
||||
*/
|
||||
"individualChat": string;
|
||||
/**
|
||||
* 特定ユーザーとの一対一のチャットができます。
|
||||
* 特定ユーザーと個別にメッセージのやりとりができます。
|
||||
*/
|
||||
"individualChat_description": string;
|
||||
/**
|
||||
* ルームチャット
|
||||
* グループ
|
||||
*/
|
||||
"roomChat": string;
|
||||
/**
|
||||
* 複数人でのチャットができます。
|
||||
* また、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。
|
||||
* 複数人でメッセージのやりとりができます。
|
||||
* また、個別のメッセージを許可していないユーザーとでも、相手が受け入れればやりとりできます。
|
||||
*/
|
||||
"roomChat_description": string;
|
||||
/**
|
||||
* ルームを作成
|
||||
* グループを作成
|
||||
*/
|
||||
"createRoom": string;
|
||||
/**
|
||||
* ユーザーを招待してチャットを始めましょう
|
||||
* ユーザーを招待してメッセージを送信しましょう
|
||||
*/
|
||||
"inviteUserToChat": string;
|
||||
/**
|
||||
* 作成したルーム
|
||||
* 作成したグループ
|
||||
*/
|
||||
"yourRooms": string;
|
||||
/**
|
||||
* 参加中のルーム
|
||||
* 参加中のグループ
|
||||
*/
|
||||
"joiningRooms": string;
|
||||
/**
|
||||
|
@ -5598,7 +5680,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"noHistory": string;
|
||||
/**
|
||||
* ルームはありません
|
||||
* グループはありません
|
||||
*/
|
||||
"noRooms": string;
|
||||
/**
|
||||
|
@ -5618,7 +5700,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"ignore": string;
|
||||
/**
|
||||
* ルームから退出
|
||||
* グループから退出
|
||||
*/
|
||||
"leave": string;
|
||||
/**
|
||||
|
@ -5642,35 +5724,35 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"newline": string;
|
||||
/**
|
||||
* このルームをミュート
|
||||
* このグループをミュート
|
||||
*/
|
||||
"muteThisRoom": string;
|
||||
/**
|
||||
* ルームを削除
|
||||
* グループを削除
|
||||
*/
|
||||
"deleteRoom": string;
|
||||
/**
|
||||
* このサーバー、またはこのアカウントでチャットは有効化されていません。
|
||||
* このサーバー、またはこのアカウントでダイレクトメッセージは有効化されていません。
|
||||
*/
|
||||
"chatNotAvailableForThisAccountOrServer": string;
|
||||
/**
|
||||
* このサーバー、またはこのアカウントでチャットは読み取り専用となっています。新たに書き込んだり、チャットルームを作成・参加したりすることはできません。
|
||||
* このサーバー、またはこのアカウントでダイレクトメッセージは読み取り専用となっています。新たに書き込んだり、グループを作成・参加したりすることはできません。
|
||||
*/
|
||||
"chatIsReadOnlyForThisAccountOrServer": string;
|
||||
/**
|
||||
* 相手のアカウントでチャット機能が使えない状態になっています。
|
||||
* 相手のアカウントでダイレクトメッセージが使えない状態になっています。
|
||||
*/
|
||||
"chatNotAvailableInOtherAccount": string;
|
||||
/**
|
||||
* このユーザーとのチャットを開始できません
|
||||
* このユーザーとのダイレクトメッセージを開始できません
|
||||
*/
|
||||
"cannotChatWithTheUser": string;
|
||||
/**
|
||||
* チャットが使えない状態になっているか、相手がチャットを開放していません。
|
||||
* ダイレクトメッセージが使えない状態になっているか、相手がダイレクトメッセージを開放していません。
|
||||
*/
|
||||
"cannotChatWithTheUser_description": string;
|
||||
/**
|
||||
* あなたはこのルームの参加者ではありませんが、招待が届いています。参加するには、招待を承認してください。
|
||||
* あなたはこのグループの参加者ではありませんが、招待が届いています。参加するには、招待を承認してください。
|
||||
*/
|
||||
"youAreNotAMemberOfThisRoomButInvited": string;
|
||||
/**
|
||||
|
@ -5678,31 +5760,31 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"doYouAcceptInvitation": string;
|
||||
/**
|
||||
* チャットする
|
||||
* ダイレクトメッセージ
|
||||
*/
|
||||
"chatWithThisUser": string;
|
||||
/**
|
||||
* このユーザーはフォロワーからのみチャットを受け付けています。
|
||||
* このユーザーはフォロワーからのみメッセージを受け付けています。
|
||||
*/
|
||||
"thisUserAllowsChatOnlyFromFollowers": string;
|
||||
/**
|
||||
* このユーザーは、このユーザーがフォローしているユーザーからのみチャットを受け付けています。
|
||||
* このユーザーは、このユーザーがフォローしているユーザーからのみメッセージを受け付けています。
|
||||
*/
|
||||
"thisUserAllowsChatOnlyFromFollowing": string;
|
||||
/**
|
||||
* このユーザーは相互フォローのユーザーからのみチャットを受け付けています。
|
||||
* このユーザーは相互フォローのユーザーからのみメッセージを受け付けています。
|
||||
*/
|
||||
"thisUserAllowsChatOnlyFromMutualFollowing": string;
|
||||
/**
|
||||
* このユーザーは誰からもチャットを受け付けていません。
|
||||
* このユーザーは誰からもメッセージを受け付けていません。
|
||||
*/
|
||||
"thisUserNotAllowedChatAnyone": string;
|
||||
/**
|
||||
* チャットを許可する相手
|
||||
* メッセージを許可する相手
|
||||
*/
|
||||
"chatAllowedUsers": string;
|
||||
/**
|
||||
* 自分からチャットメッセージを送った相手とはこの設定に関わらずチャットが可能です。
|
||||
* 自分からメッセージを送った相手とはこの設定に関わらずメッセージの送受信が可能です。
|
||||
*/
|
||||
"chatAllowedUsers_note": string;
|
||||
"_chatAllowedUsers": {
|
||||
|
@ -7856,7 +7938,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"canImportUserLists": string;
|
||||
/**
|
||||
* チャットを許可
|
||||
* ダイレクトメッセージを許可
|
||||
*/
|
||||
"chatAvailability": string;
|
||||
/**
|
||||
|
@ -7875,6 +7957,10 @@ export interface Locale extends ILocale {
|
|||
* サーバーサイドのノートの下書きの作成可能数
|
||||
*/
|
||||
"noteDraftLimit": string;
|
||||
/**
|
||||
* 予約投稿の同時作成可能数
|
||||
*/
|
||||
"scheduledNoteLimit": string;
|
||||
/**
|
||||
* ウォーターマーク機能の使用可否
|
||||
*/
|
||||
|
@ -8706,7 +8792,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"badge": string;
|
||||
/**
|
||||
* チャットの背景
|
||||
* メッセージの背景
|
||||
*/
|
||||
"messageBg": string;
|
||||
/**
|
||||
|
@ -8733,7 +8819,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"reaction": string;
|
||||
/**
|
||||
* チャットのメッセージ
|
||||
* ダイレクトメッセージ
|
||||
*/
|
||||
"chatMessage": string;
|
||||
};
|
||||
|
@ -9017,11 +9103,11 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"write:following": string;
|
||||
/**
|
||||
* チャットを見る
|
||||
* ダイレクトメッセージを見る
|
||||
*/
|
||||
"read:messaging": string;
|
||||
/**
|
||||
* チャットを操作する
|
||||
* ダイレクトメッセージを操作する
|
||||
*/
|
||||
"write:messaging": string;
|
||||
/**
|
||||
|
@ -9313,11 +9399,11 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"write:report-abuse": string;
|
||||
/**
|
||||
* チャットを操作する
|
||||
* ダイレクトメッセージを操作する
|
||||
*/
|
||||
"write:chat": string;
|
||||
/**
|
||||
* チャットを閲覧する
|
||||
* ダイレクトメッセージを閲覧する
|
||||
*/
|
||||
"read:chat": string;
|
||||
};
|
||||
|
@ -9543,7 +9629,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"birthdayFollowings": string;
|
||||
/**
|
||||
* チャット
|
||||
* ダイレクトメッセージ
|
||||
*/
|
||||
"chat": string;
|
||||
};
|
||||
|
@ -10270,6 +10356,14 @@ export interface Locale extends ILocale {
|
|||
* アンケートの結果が出ました
|
||||
*/
|
||||
"pollEnded": string;
|
||||
/**
|
||||
* 予約ノートが投稿されました
|
||||
*/
|
||||
"scheduledNotePosted": string;
|
||||
/**
|
||||
* 予約ノートの投稿に失敗しました
|
||||
*/
|
||||
"scheduledNotePostFailed": string;
|
||||
/**
|
||||
* 新しい投稿
|
||||
*/
|
||||
|
@ -10283,7 +10377,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"roleAssigned": string;
|
||||
/**
|
||||
* チャットルームへ招待されました
|
||||
* ダイレクトメッセージのグループへ招待されました
|
||||
*/
|
||||
"chatRoomInvitationReceived": string;
|
||||
/**
|
||||
|
@ -10396,7 +10490,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"roleAssigned": string;
|
||||
/**
|
||||
* チャットルームへ招待された
|
||||
* ダイレクトメッセージのグループへ招待された
|
||||
*/
|
||||
"chatRoomInvitationReceived": string;
|
||||
/**
|
||||
|
@ -10578,7 +10672,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"roleTimeline": string;
|
||||
/**
|
||||
* チャット
|
||||
* ダイレクトメッセージ
|
||||
*/
|
||||
"chat": string;
|
||||
};
|
||||
|
@ -10945,7 +11039,7 @@ export interface Locale extends ILocale {
|
|||
*/
|
||||
"deleteGalleryPost": string;
|
||||
/**
|
||||
* チャットルームを削除
|
||||
* ダイレクトメッセージのグループを削除
|
||||
*/
|
||||
"deleteChatRoom": string;
|
||||
/**
|
||||
|
@ -12219,10 +12313,18 @@ export interface Locale extends ILocale {
|
|||
* テキスト
|
||||
*/
|
||||
"text": string;
|
||||
/**
|
||||
* 二次元コード
|
||||
*/
|
||||
"qr": string;
|
||||
/**
|
||||
* 位置
|
||||
*/
|
||||
"position": string;
|
||||
/**
|
||||
* マージン
|
||||
*/
|
||||
"margin": string;
|
||||
/**
|
||||
* タイプ
|
||||
*/
|
||||
|
@ -12279,6 +12381,10 @@ export interface Locale extends ILocale {
|
|||
* サブドットの数
|
||||
*/
|
||||
"polkadotSubDotDivisions": string;
|
||||
/**
|
||||
* 空欄にするとアカウントのURLになります
|
||||
*/
|
||||
"leaveBlankToAccountUrl": string;
|
||||
};
|
||||
"_imageEffector": {
|
||||
/**
|
||||
|
@ -12318,6 +12424,14 @@ export interface Locale extends ILocale {
|
|||
* 白黒
|
||||
*/
|
||||
"grayscale": string;
|
||||
/**
|
||||
* ぼかし
|
||||
*/
|
||||
"blur": string;
|
||||
/**
|
||||
* モザイク
|
||||
*/
|
||||
"pixelate": string;
|
||||
/**
|
||||
* 色調補正
|
||||
*/
|
||||
|
@ -12362,6 +12476,10 @@ export interface Locale extends ILocale {
|
|||
* ティアリング
|
||||
*/
|
||||
"tearing": string;
|
||||
/**
|
||||
* 塗りつぶし
|
||||
*/
|
||||
"fill": string;
|
||||
};
|
||||
"_fxProps": {
|
||||
/**
|
||||
|
@ -12376,6 +12494,18 @@ export interface Locale extends ILocale {
|
|||
* サイズ
|
||||
*/
|
||||
"size": string;
|
||||
/**
|
||||
* 半径
|
||||
*/
|
||||
"radius": string;
|
||||
/**
|
||||
* サンプル数
|
||||
*/
|
||||
"samples": string;
|
||||
/**
|
||||
* 位置
|
||||
*/
|
||||
"offset": string;
|
||||
/**
|
||||
* 色
|
||||
*/
|
||||
|
@ -12488,6 +12618,10 @@ export interface Locale extends ILocale {
|
|||
* 黒色にする
|
||||
*/
|
||||
"zoomLinesBlack": string;
|
||||
/**
|
||||
* 円形
|
||||
*/
|
||||
"circle": string;
|
||||
};
|
||||
};
|
||||
/**
|
||||
|
@ -12547,6 +12681,80 @@ export interface Locale extends ILocale {
|
|||
* 下書き一覧
|
||||
*/
|
||||
"listDrafts": string;
|
||||
/**
|
||||
* 投稿予約
|
||||
*/
|
||||
"schedule": string;
|
||||
/**
|
||||
* 予約投稿一覧
|
||||
*/
|
||||
"listScheduledNotes": string;
|
||||
/**
|
||||
* 予約解除
|
||||
*/
|
||||
"cancelSchedule": string;
|
||||
};
|
||||
/**
|
||||
* 二次元コード
|
||||
*/
|
||||
"qr": string;
|
||||
"_qr": {
|
||||
/**
|
||||
* 表示
|
||||
*/
|
||||
"showTabTitle": string;
|
||||
/**
|
||||
* 読み取る
|
||||
*/
|
||||
"readTabTitle": string;
|
||||
/**
|
||||
* {name} {acct}
|
||||
*/
|
||||
"shareTitle": ParameterizedString<"name" | "acct">;
|
||||
/**
|
||||
* Fediverseで私をフォローしてください!
|
||||
*/
|
||||
"shareText": string;
|
||||
/**
|
||||
* カメラを選択
|
||||
*/
|
||||
"chooseCamera": string;
|
||||
/**
|
||||
* ライト選択不可
|
||||
*/
|
||||
"cannotToggleFlash": string;
|
||||
/**
|
||||
* ライトをオンにする
|
||||
*/
|
||||
"turnOnFlash": string;
|
||||
/**
|
||||
* ライトをオフにする
|
||||
*/
|
||||
"turnOffFlash": string;
|
||||
/**
|
||||
* コードリーダーを再開
|
||||
*/
|
||||
"startQr": string;
|
||||
/**
|
||||
* コードリーダーを停止
|
||||
*/
|
||||
"stopQr": string;
|
||||
/**
|
||||
* QRコードが見つかりません
|
||||
*/
|
||||
"noQrCodeFound": string;
|
||||
/**
|
||||
* 端末の画像をスキャン
|
||||
*/
|
||||
"scanFile": string;
|
||||
/**
|
||||
* テキスト
|
||||
*/
|
||||
"raw": string;
|
||||
/**
|
||||
* MFM
|
||||
*/
|
||||
"mfm": string;
|
||||
};
|
||||
}
|
||||
declare const locales: {
|
||||
|
|
|
@ -253,6 +253,7 @@ noteDeleteConfirm: "このノートを削除しますか?"
|
|||
pinLimitExceeded: "これ以上ピン留めできません"
|
||||
done: "完了"
|
||||
processing: "処理中"
|
||||
preprocessing: "準備中"
|
||||
preview: "プレビュー"
|
||||
default: "デフォルト"
|
||||
defaultValueIs: "デフォルト: {value}"
|
||||
|
@ -302,7 +303,7 @@ uploadNFiles: "{n}個のファイルをアップロード"
|
|||
explore: "みつける"
|
||||
messageRead: "既読"
|
||||
noMoreHistory: "これより過去の履歴はありません"
|
||||
startChat: "チャットを始める"
|
||||
startChat: "メッセージを送る"
|
||||
nUsersRead: "{n}人が読みました"
|
||||
agreeTo: "{0}に同意"
|
||||
agree: "同意する"
|
||||
|
@ -477,7 +478,7 @@ notFoundDescription: "指定されたURLに該当するページはありませ
|
|||
uploadFolder: "既定アップロード先"
|
||||
markAsReadAllNotifications: "すべての通知を既読にする"
|
||||
markAsReadAllUnreadNotes: "すべての投稿を既読にする"
|
||||
markAsReadAllTalkMessages: "すべてのチャットを既読にする"
|
||||
markAsReadAllTalkMessages: "すべてのダイレクトメッセージを既読にする"
|
||||
help: "ヘルプ"
|
||||
inputMessageHere: "ここにメッセージを入力"
|
||||
close: "閉じる"
|
||||
|
@ -1316,6 +1317,7 @@ acknowledgeNotesAndEnable: "注意事項を理解した上でオンにします
|
|||
federationSpecified: "このサーバーはホワイトリスト連合で運用されています。管理者が指定したサーバー以外とやり取りすることはできません。"
|
||||
federationDisabled: "このサーバーは連合が無効化されています。他のサーバーのユーザーとやり取りすることはできません。"
|
||||
draft: "下書き"
|
||||
draftsAndScheduledNotes: "下書きと予約投稿"
|
||||
confirmOnReact: "リアクションする際に確認する"
|
||||
reactAreYouSure: "\" {emoji} \" をリアクションしますか?"
|
||||
markAsSensitiveConfirm: "このメディアをセンシティブとして設定しますか?"
|
||||
|
@ -1343,6 +1345,8 @@ postForm: "投稿フォーム"
|
|||
textCount: "文字数"
|
||||
information: "情報"
|
||||
chat: "チャット"
|
||||
directMessage: "ダイレクトメッセージ"
|
||||
directMessage_short: "メッセージ"
|
||||
migrateOldSettings: "旧設定情報を移行"
|
||||
migrateOldSettings_description: "通常これは自動で行われていますが、何らかの理由により上手く移行されなかった場合は手動で移行処理をトリガーできます。現在の設定情報は上書きされます。"
|
||||
compress: "圧縮"
|
||||
|
@ -1370,6 +1374,8 @@ redisplayAllTips: "全ての「ヒントとコツ」を再表示"
|
|||
hideAllTips: "全ての「ヒントとコツ」を非表示"
|
||||
defaultImageCompressionLevel: "デフォルトの画像圧縮度"
|
||||
defaultImageCompressionLevel_description: "低くすると画質を保てますが、ファイルサイズは増加します。<br>高くするとファイルサイズを減らせますが、画質は低下します。"
|
||||
defaultCompressionLevel: "デフォルトの圧縮度"
|
||||
defaultCompressionLevel_description: "低くすると品質を保てますが、ファイルサイズは増加します。<br>高くするとファイルサイズを減らせますが、品質は低下します。"
|
||||
inMinutes: "分"
|
||||
inDays: "日"
|
||||
safeModeEnabled: "セーフモードが有効です"
|
||||
|
@ -1377,53 +1383,70 @@ pluginsAreDisabledBecauseSafeMode: "セーフモードが有効なため、プ
|
|||
customCssIsDisabledBecauseSafeMode: "セーフモードが有効なため、カスタムCSSは適用されていません。"
|
||||
themeIsDefaultBecauseSafeMode: "セーフモードが有効な間はデフォルトのテーマが使用されます。セーフモードをオフにすると元に戻ります。"
|
||||
thankYouForTestingBeta: "ベータ版の検証にご協力いただきありがとうございます!"
|
||||
createUserSpecifiedNote: "ユーザー指定ノートを作成"
|
||||
schedulePost: "投稿を予約"
|
||||
scheduleToPostOnX: "{x}に投稿を予約します"
|
||||
scheduledToPostOnX: "{x}に投稿が予約されています"
|
||||
schedule: "予約"
|
||||
scheduled: "予約"
|
||||
|
||||
_compression:
|
||||
_quality:
|
||||
high: "高品質"
|
||||
medium: "中品質"
|
||||
low: "低品質"
|
||||
_size:
|
||||
large: "サイズ大"
|
||||
medium: "サイズ中"
|
||||
small: "サイズ小"
|
||||
|
||||
_order:
|
||||
newest: "新しい順"
|
||||
oldest: "古い順"
|
||||
|
||||
_chat:
|
||||
messages: "メッセージ"
|
||||
noMessagesYet: "まだメッセージはありません"
|
||||
newMessage: "新しいメッセージ"
|
||||
individualChat: "個人チャット"
|
||||
individualChat_description: "特定ユーザーとの一対一のチャットができます。"
|
||||
roomChat: "ルームチャット"
|
||||
roomChat_description: "複数人でのチャットができます。\nまた、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。"
|
||||
createRoom: "ルームを作成"
|
||||
inviteUserToChat: "ユーザーを招待してチャットを始めましょう"
|
||||
yourRooms: "作成したルーム"
|
||||
joiningRooms: "参加中のルーム"
|
||||
individualChat: "個別"
|
||||
individualChat_description: "特定ユーザーと個別にメッセージのやりとりができます。"
|
||||
roomChat: "グループ"
|
||||
roomChat_description: "複数人でメッセージのやりとりができます。\nまた、個別のメッセージを許可していないユーザーとでも、相手が受け入れればやりとりできます。"
|
||||
createRoom: "グループを作成"
|
||||
inviteUserToChat: "ユーザーを招待してメッセージを送信しましょう"
|
||||
yourRooms: "作成したグループ"
|
||||
joiningRooms: "参加中のグループ"
|
||||
invitations: "招待"
|
||||
noInvitations: "招待はありません"
|
||||
history: "履歴"
|
||||
noHistory: "履歴はありません"
|
||||
noRooms: "ルームはありません"
|
||||
noRooms: "グループはありません"
|
||||
inviteUser: "ユーザーを招待"
|
||||
sentInvitations: "送信した招待"
|
||||
join: "参加"
|
||||
ignore: "無視"
|
||||
leave: "ルームから退出"
|
||||
leave: "グループから退出"
|
||||
members: "メンバー"
|
||||
searchMessages: "メッセージを検索"
|
||||
home: "ホーム"
|
||||
send: "送信"
|
||||
newline: "改行"
|
||||
muteThisRoom: "このルームをミュート"
|
||||
deleteRoom: "ルームを削除"
|
||||
chatNotAvailableForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは有効化されていません。"
|
||||
chatIsReadOnlyForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは読み取り専用となっています。新たに書き込んだり、チャットルームを作成・参加したりすることはできません。"
|
||||
chatNotAvailableInOtherAccount: "相手のアカウントでチャット機能が使えない状態になっています。"
|
||||
cannotChatWithTheUser: "このユーザーとのチャットを開始できません"
|
||||
cannotChatWithTheUser_description: "チャットが使えない状態になっているか、相手がチャットを開放していません。"
|
||||
youAreNotAMemberOfThisRoomButInvited: "あなたはこのルームの参加者ではありませんが、招待が届いています。参加するには、招待を承認してください。"
|
||||
muteThisRoom: "このグループをミュート"
|
||||
deleteRoom: "グループを削除"
|
||||
chatNotAvailableForThisAccountOrServer: "このサーバー、またはこのアカウントでダイレクトメッセージは有効化されていません。"
|
||||
chatIsReadOnlyForThisAccountOrServer: "このサーバー、またはこのアカウントでダイレクトメッセージは読み取り専用となっています。新たに書き込んだり、グループを作成・参加したりすることはできません。"
|
||||
chatNotAvailableInOtherAccount: "相手のアカウントでダイレクトメッセージが使えない状態になっています。"
|
||||
cannotChatWithTheUser: "このユーザーとのダイレクトメッセージを開始できません"
|
||||
cannotChatWithTheUser_description: "ダイレクトメッセージが使えない状態になっているか、相手がダイレクトメッセージを開放していません。"
|
||||
youAreNotAMemberOfThisRoomButInvited: "あなたはこのグループの参加者ではありませんが、招待が届いています。参加するには、招待を承認してください。"
|
||||
doYouAcceptInvitation: "招待を承認しますか?"
|
||||
chatWithThisUser: "チャットする"
|
||||
thisUserAllowsChatOnlyFromFollowers: "このユーザーはフォロワーからのみチャットを受け付けています。"
|
||||
thisUserAllowsChatOnlyFromFollowing: "このユーザーは、このユーザーがフォローしているユーザーからのみチャットを受け付けています。"
|
||||
thisUserAllowsChatOnlyFromMutualFollowing: "このユーザーは相互フォローのユーザーからのみチャットを受け付けています。"
|
||||
thisUserNotAllowedChatAnyone: "このユーザーは誰からもチャットを受け付けていません。"
|
||||
chatAllowedUsers: "チャットを許可する相手"
|
||||
chatAllowedUsers_note: "自分からチャットメッセージを送った相手とはこの設定に関わらずチャットが可能です。"
|
||||
chatWithThisUser: "ダイレクトメッセージ"
|
||||
thisUserAllowsChatOnlyFromFollowers: "このユーザーはフォロワーからのみメッセージを受け付けています。"
|
||||
thisUserAllowsChatOnlyFromFollowing: "このユーザーは、このユーザーがフォローしているユーザーからのみメッセージを受け付けています。"
|
||||
thisUserAllowsChatOnlyFromMutualFollowing: "このユーザーは相互フォローのユーザーからのみメッセージを受け付けています。"
|
||||
thisUserNotAllowedChatAnyone: "このユーザーは誰からもメッセージを受け付けていません。"
|
||||
chatAllowedUsers: "メッセージを許可する相手"
|
||||
chatAllowedUsers_note: "自分からメッセージを送った相手とはこの設定に関わらずメッセージの送受信が可能です。"
|
||||
_chatAllowedUsers:
|
||||
everyone: "誰でも"
|
||||
followers: "自分のフォロワーのみ"
|
||||
|
@ -2034,11 +2057,12 @@ _role:
|
|||
canImportFollowing: "フォローのインポートを許可"
|
||||
canImportMuting: "ミュートのインポートを許可"
|
||||
canImportUserLists: "リストのインポートを許可"
|
||||
chatAvailability: "チャットを許可"
|
||||
chatAvailability: "ダイレクトメッセージを許可"
|
||||
uploadableFileTypes: "アップロード可能なファイル種別"
|
||||
uploadableFileTypes_caption: "MIMEタイプを指定します。改行で区切って複数指定できるほか、アスタリスク(*)でワイルドカード指定できます。(例: image/*)"
|
||||
uploadableFileTypes_caption2: "ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。"
|
||||
noteDraftLimit: "サーバーサイドのノートの下書きの作成可能数"
|
||||
scheduledNoteLimit: "予約投稿の同時作成可能数"
|
||||
watermarkAvailable: "ウォーターマーク機能の使用可否"
|
||||
_condition:
|
||||
roleAssignedTo: "マニュアルロールにアサイン済み"
|
||||
|
@ -2281,7 +2305,7 @@ _theme:
|
|||
buttonHoverBg: "ボタンの背景 (ホバー)"
|
||||
inputBorder: "入力ボックスの縁取り"
|
||||
badge: "バッジ"
|
||||
messageBg: "チャットの背景"
|
||||
messageBg: "メッセージの背景"
|
||||
fgHighlighted: "強調された文字"
|
||||
|
||||
_sfx:
|
||||
|
@ -2289,7 +2313,7 @@ _sfx:
|
|||
noteMy: "ノート(自分)"
|
||||
notification: "通知"
|
||||
reaction: "リアクション選択時"
|
||||
chatMessage: "チャットのメッセージ"
|
||||
chatMessage: "ダイレクトメッセージ"
|
||||
|
||||
_soundSettings:
|
||||
driveFile: "ドライブの音声を使用"
|
||||
|
@ -2369,8 +2393,8 @@ _permissions:
|
|||
"write:favorites": "お気に入りを操作する"
|
||||
"read:following": "フォローの情報を見る"
|
||||
"write:following": "フォロー・フォロー解除する"
|
||||
"read:messaging": "チャットを見る"
|
||||
"write:messaging": "チャットを操作する"
|
||||
"read:messaging": "ダイレクトメッセージを見る"
|
||||
"write:messaging": "ダイレクトメッセージを操作する"
|
||||
"read:mutes": "ミュートを見る"
|
||||
"write:mutes": "ミュートを操作する"
|
||||
"write:notes": "ノートを作成・削除する"
|
||||
|
@ -2443,8 +2467,8 @@ _permissions:
|
|||
"read:clip-favorite": "クリップのいいねを見る"
|
||||
"read:federation": "連合に関する情報を取得する"
|
||||
"write:report-abuse": "違反を報告する"
|
||||
"write:chat": "チャットを操作する"
|
||||
"read:chat": "チャットを閲覧する"
|
||||
"write:chat": "ダイレクトメッセージを操作する"
|
||||
"read:chat": "ダイレクトメッセージを閲覧する"
|
||||
|
||||
_auth:
|
||||
shareAccessTitle: "アプリへのアクセス許可"
|
||||
|
@ -2507,7 +2531,7 @@ _widgets:
|
|||
chooseList: "リストを選択"
|
||||
clicker: "クリッカー"
|
||||
birthdayFollowings: "今日誕生日のユーザー"
|
||||
chat: "チャット"
|
||||
chat: "ダイレクトメッセージ"
|
||||
|
||||
_cw:
|
||||
hide: "隠す"
|
||||
|
@ -2711,10 +2735,12 @@ _notification:
|
|||
youReceivedFollowRequest: "フォローリクエストが来ました"
|
||||
yourFollowRequestAccepted: "フォローリクエストが承認されました"
|
||||
pollEnded: "アンケートの結果が出ました"
|
||||
scheduledNotePosted: "予約ノートが投稿されました"
|
||||
scheduledNotePostFailed: "予約ノートの投稿に失敗しました"
|
||||
newNote: "新しい投稿"
|
||||
unreadAntennaNote: "アンテナ {name}"
|
||||
roleAssigned: "ロールが付与されました"
|
||||
chatRoomInvitationReceived: "チャットルームへ招待されました"
|
||||
chatRoomInvitationReceived: "ダイレクトメッセージのグループへ招待されました"
|
||||
emptyPushNotificationMessage: "プッシュ通知の更新をしました"
|
||||
achievementEarned: "実績を獲得"
|
||||
testNotification: "通知テスト"
|
||||
|
@ -2744,7 +2770,7 @@ _notification:
|
|||
receiveFollowRequest: "フォロー申請を受け取った"
|
||||
followRequestAccepted: "フォローが受理された"
|
||||
roleAssigned: "ロールが付与された"
|
||||
chatRoomInvitationReceived: "チャットルームへ招待された"
|
||||
chatRoomInvitationReceived: "ダイレクトメッセージのグループへ招待された"
|
||||
achievementEarned: "実績の獲得"
|
||||
exportCompleted: "エクスポートが完了した"
|
||||
login: "ログイン"
|
||||
|
@ -2794,7 +2820,7 @@ _deck:
|
|||
mentions: "メンション"
|
||||
direct: "指名"
|
||||
roleTimeline: "ロールタイムライン"
|
||||
chat: "チャット"
|
||||
chat: "ダイレクトメッセージ"
|
||||
|
||||
_dialog:
|
||||
charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"
|
||||
|
@ -2897,7 +2923,7 @@ _moderationLogTypes:
|
|||
deletePage: "ページを削除"
|
||||
deleteFlash: "Playを削除"
|
||||
deleteGalleryPost: "ギャラリーの投稿を削除"
|
||||
deleteChatRoom: "チャットルームを削除"
|
||||
deleteChatRoom: "ダイレクトメッセージのグループを削除"
|
||||
updateProxyAccountDescription: "プロキシアカウントの説明を更新"
|
||||
|
||||
_fileViewer:
|
||||
|
@ -3271,7 +3297,9 @@ _watermarkEditor:
|
|||
opacity: "不透明度"
|
||||
scale: "サイズ"
|
||||
text: "テキスト"
|
||||
qr: "二次元コード"
|
||||
position: "位置"
|
||||
margin: "マージン"
|
||||
type: "タイプ"
|
||||
image: "画像"
|
||||
advanced: "高度"
|
||||
|
@ -3286,6 +3314,7 @@ _watermarkEditor:
|
|||
polkadotSubDotOpacity: "サブドットの不透明度"
|
||||
polkadotSubDotRadius: "サブドットの大きさ"
|
||||
polkadotSubDotDivisions: "サブドットの数"
|
||||
leaveBlankToAccountUrl: "空欄にするとアカウントのURLになります"
|
||||
|
||||
_imageEffector:
|
||||
title: "エフェクト"
|
||||
|
@ -3299,6 +3328,8 @@ _imageEffector:
|
|||
mirror: "ミラー"
|
||||
invert: "色の反転"
|
||||
grayscale: "白黒"
|
||||
blur: "ぼかし"
|
||||
pixelate: "モザイク"
|
||||
colorAdjust: "色調補正"
|
||||
colorClamp: "色の圧縮"
|
||||
colorClampAdvanced: "色の圧縮(高度)"
|
||||
|
@ -3310,11 +3341,15 @@ _imageEffector:
|
|||
checker: "チェッカー"
|
||||
blockNoise: "ブロックノイズ"
|
||||
tearing: "ティアリング"
|
||||
fill: "塗りつぶし"
|
||||
|
||||
_fxProps:
|
||||
angle: "角度"
|
||||
scale: "サイズ"
|
||||
size: "サイズ"
|
||||
radius: "半径"
|
||||
samples: "サンプル数"
|
||||
offset: "位置"
|
||||
color: "色"
|
||||
opacity: "不透明度"
|
||||
normalize: "正規化"
|
||||
|
@ -3343,6 +3378,7 @@ _imageEffector:
|
|||
zoomLinesThreshold: "集中線の幅"
|
||||
zoomLinesMaskSize: "中心径"
|
||||
zoomLinesBlack: "黒色にする"
|
||||
circle: "円形"
|
||||
|
||||
drafts: "下書き"
|
||||
_drafts:
|
||||
|
@ -3359,3 +3395,23 @@ _drafts:
|
|||
restoreFromDraft: "下書きから復元"
|
||||
restore: "復元"
|
||||
listDrafts: "下書き一覧"
|
||||
schedule: "投稿予約"
|
||||
listScheduledNotes: "予約投稿一覧"
|
||||
cancelSchedule: "予約解除"
|
||||
|
||||
qr: "二次元コード"
|
||||
_qr:
|
||||
showTabTitle: "表示"
|
||||
readTabTitle: "読み取る"
|
||||
shareTitle: "{name} {acct}"
|
||||
shareText: "Fediverseで私をフォローしてください!"
|
||||
chooseCamera: "カメラを選択"
|
||||
cannotToggleFlash: "ライト選択不可"
|
||||
turnOnFlash: "ライトをオンにする"
|
||||
turnOffFlash: "ライトをオフにする"
|
||||
startQr: "コードリーダーを再開"
|
||||
stopQr: "コードリーダーを停止"
|
||||
noQrCodeFound: "QRコードが見つかりません"
|
||||
scanFile: "端末の画像をスキャン"
|
||||
raw: "テキスト"
|
||||
mfm: "MFM"
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "misskey",
|
||||
"version": "2025.9.0",
|
||||
"version": "2025.9.1-alpha.2",
|
||||
"codename": "nasubi",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/misskey-dev/misskey.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.15.1",
|
||||
"packageManager": "pnpm@10.16.0",
|
||||
"workspaces": [
|
||||
"packages/frontend-shared",
|
||||
"packages/frontend",
|
||||
|
@ -76,7 +76,7 @@
|
|||
"eslint": "9.35.0",
|
||||
"globals": "16.3.0",
|
||||
"ncp": "2.0.0",
|
||||
"pnpm": "10.15.1",
|
||||
"pnpm": "10.16.0",
|
||||
"start-server-and-test": "2.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class SensitiveAd1757823175259 {
|
||||
name = 'SensitiveAd1757823175259'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "ad" ADD "isSensitive" boolean NOT NULL DEFAULT false`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "ad" DROP COLUMN "isSensitive"`);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class ScheduledPost1758677617888 {
|
||||
name = 'ScheduledPost1758677617888'
|
||||
|
||||
/**
|
||||
* @param {QueryRunner} queryRunner
|
||||
*/
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ADD "scheduledAt" TIMESTAMP WITH TIME ZONE`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" ADD "isActuallyScheduled" boolean NOT NULL DEFAULT false`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {QueryRunner} queryRunner
|
||||
*/
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" DROP COLUMN "isActuallyScheduled"`);
|
||||
await queryRunner.query(`ALTER TABLE "note_draft" DROP COLUMN "scheduledAt"`);
|
||||
}
|
||||
}
|
|
@ -7,6 +7,7 @@ import * as fs from 'node:fs';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { type FastifyServerOptions } from 'fastify';
|
||||
import type * as Sentry from '@sentry/node';
|
||||
import type * as SentryVue from '@sentry/vue';
|
||||
import type { RedisOptions } from 'ioredis';
|
||||
|
@ -27,6 +28,7 @@ type Source = {
|
|||
url?: string;
|
||||
port?: number;
|
||||
socket?: string;
|
||||
trustProxy?: FastifyServerOptions['trustProxy'];
|
||||
chmodSocket?: string;
|
||||
disableHsts?: boolean;
|
||||
db: {
|
||||
|
@ -118,6 +120,7 @@ export type Config = {
|
|||
url: string;
|
||||
port: number;
|
||||
socket: string | undefined;
|
||||
trustProxy: FastifyServerOptions['trustProxy'];
|
||||
chmodSocket: string | undefined;
|
||||
disableHsts: boolean | undefined;
|
||||
db: {
|
||||
|
@ -266,6 +269,7 @@ export function loadConfig(): Config {
|
|||
url: url.origin,
|
||||
port: config.port ?? parseInt(process.env.PORT ?? '', 10),
|
||||
socket: config.socket,
|
||||
trustProxy: config.trustProxy,
|
||||
chmodSocket: config.chmodSocket,
|
||||
disableHsts: config.disableHsts,
|
||||
host,
|
||||
|
|
|
@ -13,7 +13,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
|
|||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||
import type { IMentionedRemoteUsers } from '@/models/Note.js';
|
||||
import { MiNote } from '@/models/Note.js';
|
||||
import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { BlockingsRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import type { MiApp } from '@/models/App.js';
|
||||
import { concat } from '@/misc/prelude/array.js';
|
||||
|
@ -56,6 +56,7 @@ import { trackPromise } from '@/misc/promise-tracker.js';
|
|||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { isQuote, isRenote } from '@/misc/is-renote.js';
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||
|
||||
|
@ -192,6 +193,12 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private idService: IdService,
|
||||
|
@ -221,6 +228,167 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async fetchAndCreate(user: {
|
||||
id: MiUser['id'];
|
||||
username: MiUser['username'];
|
||||
host: MiUser['host'];
|
||||
isBot: MiUser['isBot'];
|
||||
isCat: MiUser['isCat'];
|
||||
}, data: {
|
||||
createdAt: Date;
|
||||
replyId: MiNote['id'] | null;
|
||||
renoteId: MiNote['id'] | null;
|
||||
fileIds: MiDriveFile['id'][];
|
||||
text: string | null;
|
||||
cw: string | null;
|
||||
visibility: string;
|
||||
visibleUserIds: MiUser['id'][];
|
||||
channelId: MiChannel['id'] | null;
|
||||
localOnly: boolean;
|
||||
reactionAcceptance: MiNote['reactionAcceptance'];
|
||||
poll: IPoll | null;
|
||||
apMentions?: MinimumUser[] | null;
|
||||
apHashtags?: string[] | null;
|
||||
apEmojis?: string[] | null;
|
||||
}): Promise<MiNote> {
|
||||
const visibleUsers = data.visibleUserIds.length > 0 ? await this.usersRepository.findBy({
|
||||
id: In(data.visibleUserIds),
|
||||
}) : [];
|
||||
|
||||
let files: MiDriveFile[] = [];
|
||||
if (data.fileIds.length > 0) {
|
||||
files = await this.driveFilesRepository.createQueryBuilder('file')
|
||||
.where('file.userId = :userId AND file.id IN (:...fileIds)', {
|
||||
userId: user.id,
|
||||
fileIds: data.fileIds,
|
||||
})
|
||||
.orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
|
||||
.setParameters({ fileIds: data.fileIds })
|
||||
.getMany();
|
||||
|
||||
if (files.length !== data.fileIds.length) {
|
||||
throw new IdentifiableError('801c046c-5bf5-4234-ad2b-e78fc20a2ac7', 'No such file');
|
||||
}
|
||||
}
|
||||
|
||||
let renote: MiNote | null = null;
|
||||
if (data.renoteId != null) {
|
||||
// Fetch renote to note
|
||||
renote = await this.notesRepository.findOne({
|
||||
where: { id: data.renoteId },
|
||||
relations: ['user', 'renote', 'reply'],
|
||||
});
|
||||
|
||||
if (renote == null) {
|
||||
throw new IdentifiableError('53983c56-e163-45a6-942f-4ddc485d4290', 'No such renote target');
|
||||
} else if (isRenote(renote) && !isQuote(renote)) {
|
||||
throw new IdentifiableError('bde24c37-121f-4e7d-980d-cec52f599f02', 'Cannot renote pure renote');
|
||||
}
|
||||
|
||||
// Check blocking
|
||||
if (renote.userId !== user.id) {
|
||||
const blockExist = await this.blockingsRepository.exists({
|
||||
where: {
|
||||
blockerId: renote.userId,
|
||||
blockeeId: user.id,
|
||||
},
|
||||
});
|
||||
if (blockExist) {
|
||||
throw new IdentifiableError('2b4fe776-4414-4a2d-ae39-f3418b8fd4d3', 'You have been blocked by the user');
|
||||
}
|
||||
}
|
||||
|
||||
if (renote.visibility === 'followers' && renote.userId !== user.id) {
|
||||
// 他人のfollowers noteはreject
|
||||
throw new IdentifiableError('90b9d6f0-893a-4fef-b0f1-e9a33989f71a', 'Renote target visibility');
|
||||
} else if (renote.visibility === 'specified') {
|
||||
// specified / direct noteはreject
|
||||
throw new IdentifiableError('48d7a997-da5c-4716-b3c3-92db3f37bf7d', 'Renote target visibility');
|
||||
}
|
||||
|
||||
if (renote.channelId && renote.channelId !== data.channelId) {
|
||||
// チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック
|
||||
// リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
|
||||
const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId });
|
||||
if (renoteChannel == null) {
|
||||
// リノートしたいノートが書き込まれているチャンネルが無い
|
||||
throw new IdentifiableError('b060f9a6-8909-4080-9e0b-94d9fa6f6a77', 'No such channel');
|
||||
} else if (!renoteChannel.allowRenoteToExternal) {
|
||||
// リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合
|
||||
throw new IdentifiableError('7e435f4a-780d-4cfc-a15a-42519bd6fb67', 'Channel does not allow renote to external');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let reply: MiNote | null = null;
|
||||
if (data.replyId != null) {
|
||||
// Fetch reply
|
||||
reply = await this.notesRepository.findOne({
|
||||
where: { id: data.replyId },
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (reply == null) {
|
||||
throw new IdentifiableError('60142edb-1519-408e-926d-4f108d27bee0', 'No such reply target');
|
||||
} else if (isRenote(reply) && !isQuote(reply)) {
|
||||
throw new IdentifiableError('f089e4e2-c0e7-4f60-8a23-e5a6bf786b36', 'Cannot reply to pure renote');
|
||||
} else if (!await this.noteEntityService.isVisibleForMe(reply, user.id)) {
|
||||
throw new IdentifiableError('11cd37b3-a411-4f77-8633-c580ce6a8dce', 'No such reply target');
|
||||
} else if (reply.visibility === 'specified' && data.visibility !== 'specified') {
|
||||
throw new IdentifiableError('ced780a1-2012-4caf-bc7e-a95a291294cb', 'Cannot reply to specified note with different visibility');
|
||||
}
|
||||
|
||||
// Check blocking
|
||||
if (reply.userId !== user.id) {
|
||||
const blockExist = await this.blockingsRepository.exists({
|
||||
where: {
|
||||
blockerId: reply.userId,
|
||||
blockeeId: user.id,
|
||||
},
|
||||
});
|
||||
if (blockExist) {
|
||||
throw new IdentifiableError('b0df6025-f2e8-44b4-a26a-17ad99104612', 'You have been blocked by the user');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.poll) {
|
||||
if (data.poll.expiresAt != null) {
|
||||
if (data.poll.expiresAt.getTime() < Date.now()) {
|
||||
throw new IdentifiableError('0c11c11e-0c8d-48e7-822c-76ccef660068', 'Poll expiration must be future time');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let channel: MiChannel | null = null;
|
||||
if (data.channelId != null) {
|
||||
channel = await this.channelsRepository.findOneBy({ id: data.channelId, isArchived: false });
|
||||
|
||||
if (channel == null) {
|
||||
throw new IdentifiableError('bfa3905b-25f5-4894-b430-da331a490e4b', 'No such channel');
|
||||
}
|
||||
}
|
||||
|
||||
return this.create(user, {
|
||||
createdAt: data.createdAt,
|
||||
files: files,
|
||||
poll: data.poll,
|
||||
text: data.text,
|
||||
reply,
|
||||
renote,
|
||||
cw: data.cw,
|
||||
localOnly: data.localOnly,
|
||||
reactionAcceptance: data.reactionAcceptance,
|
||||
visibility: data.visibility,
|
||||
visibleUsers,
|
||||
channel,
|
||||
apMentions: data.apMentions,
|
||||
apHashtags: data.apHashtags,
|
||||
apEmojis: data.apEmojis,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async create(user: {
|
||||
id: MiUser['id'];
|
||||
|
|
|
@ -5,32 +5,18 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import type { noteVisibilities, noteReactionAcceptances } from '@/types.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiNoteDraft, NoteDraftsRepository, MiNote, MiDriveFile, MiChannel, UsersRepository, DriveFilesRepository, NotesRepository, BlockingsRepository, ChannelsRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||
import { IPoll } from '@/models/Poll.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { isRenote, isQuote } from '@/misc/is-renote.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
|
||||
export type NoteDraftOptions = {
|
||||
replyId?: MiNote['id'] | null;
|
||||
renoteId?: MiNote['id'] | null;
|
||||
text?: string | null;
|
||||
cw?: string | null;
|
||||
localOnly?: boolean | null;
|
||||
reactionAcceptance?: typeof noteReactionAcceptances[number];
|
||||
visibility?: typeof noteVisibilities[number];
|
||||
fileIds?: MiDriveFile['id'][];
|
||||
visibleUserIds?: MiUser['id'][];
|
||||
hashtag?: string;
|
||||
channelId?: MiChannel['id'] | null;
|
||||
poll?: (IPoll & { expiredAfter?: number | null }) | null;
|
||||
};
|
||||
export type NoteDraftOptions = Omit<MiNoteDraft, 'id' | 'userId' | 'user' | 'reply' | 'renote' | 'channel'>;
|
||||
|
||||
@Injectable()
|
||||
export class NoteDraftService {
|
||||
|
@ -56,6 +42,7 @@ export class NoteDraftService {
|
|||
private roleService: RoleService,
|
||||
private idService: IdService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private queueService: QueueService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -72,36 +59,43 @@ export class NoteDraftService {
|
|||
@bindThis
|
||||
public async create(me: MiLocalUser, data: NoteDraftOptions): Promise<MiNoteDraft> {
|
||||
//#region check draft limit
|
||||
const policies = await this.roleService.getUserPolicies(me.id);
|
||||
|
||||
const currentCount = await this.noteDraftsRepository.countBy({
|
||||
userId: me.id,
|
||||
});
|
||||
if (currentCount >= (await this.roleService.getUserPolicies(me.id)).noteDraftLimit) {
|
||||
if (currentCount >= policies.noteDraftLimit) {
|
||||
throw new IdentifiableError('9ee33bbe-fde3-4c71-9b51-e50492c6b9c8', 'Too many drafts');
|
||||
}
|
||||
|
||||
if (data.isActuallyScheduled) {
|
||||
const currentScheduledCount = await this.noteDraftsRepository.countBy({
|
||||
userId: me.id,
|
||||
isActuallyScheduled: true,
|
||||
});
|
||||
if (currentScheduledCount >= policies.scheduledNoteLimit) {
|
||||
throw new IdentifiableError('c3275f19-4558-4c59-83e1-4f684b5fab66', 'Too many scheduled notes');
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
if (data.poll) {
|
||||
if (typeof data.poll.expiresAt === 'number') {
|
||||
if (data.poll.expiresAt < Date.now()) {
|
||||
throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll');
|
||||
}
|
||||
} else if (typeof data.poll.expiredAfter === 'number') {
|
||||
data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter);
|
||||
}
|
||||
await this.validate(me, data);
|
||||
|
||||
const draft = await this.noteDraftsRepository.insertOne({
|
||||
...data,
|
||||
id: this.idService.gen(),
|
||||
userId: me.id,
|
||||
});
|
||||
|
||||
if (draft.scheduledAt && draft.isActuallyScheduled) {
|
||||
this.schedule(draft);
|
||||
}
|
||||
|
||||
const appliedDraft = await this.checkAndSetDraftNoteOptions(me, this.noteDraftsRepository.create(), data);
|
||||
|
||||
appliedDraft.id = this.idService.gen();
|
||||
appliedDraft.userId = me.id;
|
||||
const draft = this.noteDraftsRepository.save(appliedDraft);
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: NoteDraftOptions): Promise<MiNoteDraft> {
|
||||
public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: Partial<NoteDraftOptions>): Promise<MiNoteDraft> {
|
||||
const draft = await this.noteDraftsRepository.findOneBy({
|
||||
id: draftId,
|
||||
userId: me.id,
|
||||
|
@ -111,19 +105,36 @@ export class NoteDraftService {
|
|||
throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft');
|
||||
}
|
||||
|
||||
if (data.poll) {
|
||||
if (typeof data.poll.expiresAt === 'number') {
|
||||
if (data.poll.expiresAt < Date.now()) {
|
||||
throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll');
|
||||
}
|
||||
} else if (typeof data.poll.expiredAfter === 'number') {
|
||||
data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter);
|
||||
//#region check draft limit
|
||||
const policies = await this.roleService.getUserPolicies(me.id);
|
||||
|
||||
if (!draft.isActuallyScheduled && data.isActuallyScheduled) {
|
||||
const currentScheduledCount = await this.noteDraftsRepository.countBy({
|
||||
userId: me.id,
|
||||
isActuallyScheduled: true,
|
||||
});
|
||||
if (currentScheduledCount >= policies.scheduledNoteLimit) {
|
||||
throw new IdentifiableError('bacdf856-5c51-4159-b88a-804fa5103be5', 'Too many scheduled notes');
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const appliedDraft = await this.checkAndSetDraftNoteOptions(me, draft, data);
|
||||
await this.validate(me, data);
|
||||
|
||||
return await this.noteDraftsRepository.save(appliedDraft);
|
||||
const updatedDraft = await this.noteDraftsRepository.createQueryBuilder().update()
|
||||
.set(data)
|
||||
.where('id = :id', { id: draftId })
|
||||
.returning('*')
|
||||
.execute()
|
||||
.then((response) => response.raw[0]);
|
||||
|
||||
this.clearSchedule(draftId).then(() => {
|
||||
if (updatedDraft.scheduledAt != null && updatedDraft.isActuallyScheduled) {
|
||||
this.schedule(updatedDraft);
|
||||
}
|
||||
});
|
||||
|
||||
return updatedDraft;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -138,6 +149,8 @@ export class NoteDraftService {
|
|||
}
|
||||
|
||||
await this.noteDraftsRepository.delete(draft.id);
|
||||
|
||||
this.clearSchedule(draftId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -154,27 +167,20 @@ export class NoteDraftService {
|
|||
return draft;
|
||||
}
|
||||
|
||||
// 関連エンティティを取得し紐づける部分を共通化する
|
||||
@bindThis
|
||||
public async checkAndSetDraftNoteOptions(
|
||||
public async validate(
|
||||
me: MiLocalUser,
|
||||
draft: MiNoteDraft,
|
||||
data: NoteDraftOptions,
|
||||
): Promise<MiNoteDraft> {
|
||||
data.visibility ??= 'public';
|
||||
data.localOnly ??= false;
|
||||
if (data.reactionAcceptance === undefined) data.reactionAcceptance = null;
|
||||
if (data.channelId != null) {
|
||||
data.visibility = 'public';
|
||||
data.visibleUserIds = [];
|
||||
data.localOnly = true;
|
||||
data: Partial<NoteDraftOptions>,
|
||||
): Promise<void> {
|
||||
if (data.pollExpiresAt != null) {
|
||||
if (data.pollExpiresAt.getTime() < Date.now()) {
|
||||
throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll');
|
||||
}
|
||||
}
|
||||
|
||||
let appliedDraft = draft;
|
||||
|
||||
//#region visibleUsers
|
||||
let visibleUsers: MiUser[] = [];
|
||||
if (data.visibleUserIds != null) {
|
||||
if (data.visibleUserIds != null && data.visibleUserIds.length > 0) {
|
||||
visibleUsers = await this.usersRepository.findBy({
|
||||
id: In(data.visibleUserIds),
|
||||
});
|
||||
|
@ -184,7 +190,7 @@ export class NoteDraftService {
|
|||
//#region files
|
||||
let files: MiDriveFile[] = [];
|
||||
const fileIds = data.fileIds ?? null;
|
||||
if (fileIds != null) {
|
||||
if (fileIds != null && fileIds.length > 0) {
|
||||
files = await this.driveFilesRepository.createQueryBuilder('file')
|
||||
.where('file.userId = :userId AND file.id IN (:...fileIds)', {
|
||||
userId: me.id,
|
||||
|
@ -288,27 +294,37 @@ export class NoteDraftService {
|
|||
}
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
appliedDraft = {
|
||||
...appliedDraft,
|
||||
visibility: data.visibility,
|
||||
cw: data.cw ?? null,
|
||||
fileIds: fileIds ?? [],
|
||||
replyId: data.replyId ?? null,
|
||||
renoteId: data.renoteId ?? null,
|
||||
channelId: data.channelId ?? null,
|
||||
text: data.text ?? null,
|
||||
hashtag: data.hashtag ?? null,
|
||||
hasPoll: data.poll != null,
|
||||
pollChoices: data.poll ? data.poll.choices : [],
|
||||
pollMultiple: data.poll ? data.poll.multiple : false,
|
||||
pollExpiresAt: data.poll ? data.poll.expiresAt : null,
|
||||
pollExpiredAfter: data.poll ? data.poll.expiredAfter ?? null : null,
|
||||
visibleUserIds: data.visibleUserIds ?? [],
|
||||
localOnly: data.localOnly,
|
||||
reactionAcceptance: data.reactionAcceptance,
|
||||
} satisfies MiNoteDraft;
|
||||
@bindThis
|
||||
public async schedule(draft: MiNoteDraft): Promise<void> {
|
||||
if (!draft.isActuallyScheduled) return;
|
||||
if (draft.scheduledAt == null) return;
|
||||
if (draft.scheduledAt.getTime() <= Date.now()) return;
|
||||
|
||||
return appliedDraft;
|
||||
const delay = draft.scheduledAt.getTime() - Date.now();
|
||||
this.queueService.postScheduledNoteQueue.add(draft.id, {
|
||||
noteDraftId: draft.id,
|
||||
}, {
|
||||
delay,
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 100,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async clearSchedule(draftId: MiNoteDraft['id']): Promise<void> {
|
||||
const jobs = await this.queueService.postScheduledNoteQueue.getJobs(['delayed', 'waiting', 'active']);
|
||||
for (const job of jobs) {
|
||||
if (job.data.noteDraftId === draftId) {
|
||||
await job.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,11 +16,13 @@ import {
|
|||
RelationshipJobData,
|
||||
UserWebhookDeliverJobData,
|
||||
SystemWebhookDeliverJobData,
|
||||
PostScheduledNoteJobData,
|
||||
} from '../queue/types.js';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
|
||||
export type SystemQueue = Bull.Queue<Record<string, unknown>>;
|
||||
export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
|
||||
export type PostScheduledNoteQueue = Bull.Queue<PostScheduledNoteJobData>;
|
||||
export type DeliverQueue = Bull.Queue<DeliverJobData>;
|
||||
export type InboxQueue = Bull.Queue<InboxJobData>;
|
||||
export type DbQueue = Bull.Queue;
|
||||
|
@ -41,6 +43,12 @@ const $endedPollNotification: Provider = {
|
|||
inject: [DI.config],
|
||||
};
|
||||
|
||||
const $postScheduledNote: Provider = {
|
||||
provide: 'queue:postScheduledNote',
|
||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.POST_SCHEDULED_NOTE, baseQueueOptions(config, QUEUE.POST_SCHEDULED_NOTE)),
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
||||
const $deliver: Provider = {
|
||||
provide: 'queue:deliver',
|
||||
useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)),
|
||||
|
@ -89,6 +97,7 @@ const $systemWebhookDeliver: Provider = {
|
|||
providers: [
|
||||
$system,
|
||||
$endedPollNotification,
|
||||
$postScheduledNote,
|
||||
$deliver,
|
||||
$inbox,
|
||||
$db,
|
||||
|
@ -100,6 +109,7 @@ const $systemWebhookDeliver: Provider = {
|
|||
exports: [
|
||||
$system,
|
||||
$endedPollNotification,
|
||||
$postScheduledNote,
|
||||
$deliver,
|
||||
$inbox,
|
||||
$db,
|
||||
|
@ -113,6 +123,7 @@ export class QueueModule implements OnApplicationShutdown {
|
|||
constructor(
|
||||
@Inject('queue:system') public systemQueue: SystemQueue,
|
||||
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
|
||||
@Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue,
|
||||
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
|
||||
@Inject('queue:inbox') public inboxQueue: InboxQueue,
|
||||
@Inject('queue:db') public dbQueue: DbQueue,
|
||||
|
@ -129,6 +140,7 @@ export class QueueModule implements OnApplicationShutdown {
|
|||
await Promise.all([
|
||||
this.systemQueue.close(),
|
||||
this.endedPollNotificationQueue.close(),
|
||||
this.postScheduledNoteQueue.close(),
|
||||
this.deliverQueue.close(),
|
||||
this.inboxQueue.close(),
|
||||
this.dbQueue.close(),
|
||||
|
|
|
@ -31,6 +31,7 @@ import type {
|
|||
DbQueue,
|
||||
DeliverQueue,
|
||||
EndedPollNotificationQueue,
|
||||
PostScheduledNoteQueue,
|
||||
InboxQueue,
|
||||
ObjectStorageQueue,
|
||||
RelationshipQueue,
|
||||
|
@ -44,6 +45,7 @@ import type * as Bull from 'bullmq';
|
|||
export const QUEUE_TYPES = [
|
||||
'system',
|
||||
'endedPollNotification',
|
||||
'postScheduledNote',
|
||||
'deliver',
|
||||
'inbox',
|
||||
'db',
|
||||
|
@ -92,6 +94,7 @@ export class QueueService {
|
|||
|
||||
@Inject('queue:system') public systemQueue: SystemQueue,
|
||||
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
|
||||
@Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue,
|
||||
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
|
||||
@Inject('queue:inbox') public inboxQueue: InboxQueue,
|
||||
@Inject('queue:db') public dbQueue: DbQueue,
|
||||
|
@ -717,6 +720,7 @@ export class QueueService {
|
|||
switch (type) {
|
||||
case 'system': return this.systemQueue;
|
||||
case 'endedPollNotification': return this.endedPollNotificationQueue;
|
||||
case 'postScheduledNote': return this.postScheduledNoteQueue;
|
||||
case 'deliver': return this.deliverQueue;
|
||||
case 'inbox': return this.inboxQueue;
|
||||
case 'db': return this.dbQueue;
|
||||
|
|
|
@ -69,6 +69,7 @@ export type RolePolicies = {
|
|||
chatAvailability: 'available' | 'readonly' | 'unavailable';
|
||||
uploadableFileTypes: string[];
|
||||
noteDraftLimit: number;
|
||||
scheduledNoteLimit: number;
|
||||
watermarkAvailable: boolean;
|
||||
};
|
||||
|
||||
|
@ -101,20 +102,22 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
userEachUserListsLimit: 50,
|
||||
rateLimitFactor: 1,
|
||||
avatarDecorationLimit: 1,
|
||||
canImportAntennas: true,
|
||||
canImportBlocking: true,
|
||||
canImportFollowing: true,
|
||||
canImportMuting: true,
|
||||
canImportUserLists: true,
|
||||
canImportAntennas: false,
|
||||
canImportBlocking: false,
|
||||
canImportFollowing: false,
|
||||
canImportMuting: false,
|
||||
canImportUserLists: false,
|
||||
chatAvailability: 'available',
|
||||
uploadableFileTypes: [
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'application/json',
|
||||
'image/*',
|
||||
'video/*',
|
||||
'audio/*',
|
||||
],
|
||||
noteDraftLimit: 10,
|
||||
scheduledNoteLimit: 1,
|
||||
watermarkAvailable: true,
|
||||
};
|
||||
|
||||
|
@ -439,6 +442,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
return [...set];
|
||||
}),
|
||||
noteDraftLimit: calc('noteDraftLimit', vs => Math.max(...vs)),
|
||||
scheduledNoteLimit: calc('scheduledNoteLimit', vs => Math.max(...vs)),
|
||||
watermarkAvailable: calc('watermarkAvailable', vs => vs.some(v => v === true)),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -117,6 +117,7 @@ export class MetaEntityService {
|
|||
ratio: ad.ratio,
|
||||
imageUrl: ad.imageUrl,
|
||||
dayOfWeek: ad.dayOfWeek,
|
||||
isSensitive: ad.isSensitive ? true : undefined,
|
||||
})),
|
||||
notesPerOneAd: instance.notesPerOneAd,
|
||||
enableEmail: instance.enableEmail,
|
||||
|
|
|
@ -105,6 +105,8 @@ export class NoteDraftEntityService implements OnModuleInit {
|
|||
const packed: Packed<'NoteDraft'> = await awaitAll({
|
||||
id: noteDraft.id,
|
||||
createdAt: this.idService.parse(noteDraft.id).date.toISOString(),
|
||||
scheduledAt: noteDraft.scheduledAt?.getTime() ?? null,
|
||||
isActuallyScheduled: noteDraft.isActuallyScheduled,
|
||||
userId: noteDraft.userId,
|
||||
user: packedUsers?.get(noteDraft.userId) ?? this.userEntityService.pack(noteDraft.user ?? noteDraft.userId, me),
|
||||
text: text,
|
||||
|
@ -112,13 +114,13 @@ export class NoteDraftEntityService implements OnModuleInit {
|
|||
visibility: noteDraft.visibility,
|
||||
localOnly: noteDraft.localOnly,
|
||||
reactionAcceptance: noteDraft.reactionAcceptance,
|
||||
visibleUserIds: noteDraft.visibility === 'specified' ? noteDraft.visibleUserIds : undefined,
|
||||
hashtag: noteDraft.hashtag ?? undefined,
|
||||
visibleUserIds: noteDraft.visibleUserIds,
|
||||
hashtag: noteDraft.hashtag,
|
||||
fileIds: noteDraft.fileIds,
|
||||
files: packedFiles != null ? this.packAttachedFiles(noteDraft.fileIds, packedFiles) : this.driveFileEntityService.packManyByIds(noteDraft.fileIds),
|
||||
replyId: noteDraft.replyId,
|
||||
renoteId: noteDraft.renoteId,
|
||||
channelId: noteDraft.channelId ?? undefined,
|
||||
channelId: noteDraft.channelId,
|
||||
channel: channel ? {
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
|
@ -127,6 +129,12 @@ export class NoteDraftEntityService implements OnModuleInit {
|
|||
allowRenoteToExternal: channel.allowRenoteToExternal,
|
||||
userId: channel.userId,
|
||||
} : undefined,
|
||||
poll: noteDraft.hasPoll ? {
|
||||
choices: noteDraft.pollChoices,
|
||||
multiple: noteDraft.pollMultiple,
|
||||
expiresAt: noteDraft.pollExpiresAt?.toISOString(),
|
||||
expiredAfter: noteDraft.pollExpiredAfter,
|
||||
} : null,
|
||||
|
||||
...(opts.detail ? {
|
||||
reply: noteDraft.replyId ? nullIfEntityNotFound(this.noteEntityService.pack(noteDraft.replyId, me, {
|
||||
|
@ -138,13 +146,6 @@ export class NoteDraftEntityService implements OnModuleInit {
|
|||
detail: true,
|
||||
skipHide: opts.skipHide,
|
||||
})) : undefined,
|
||||
|
||||
poll: noteDraft.hasPoll ? {
|
||||
choices: noteDraft.pollChoices,
|
||||
multiple: noteDraft.pollMultiple,
|
||||
expiresAt: noteDraft.pollExpiresAt?.toISOString(),
|
||||
expiredAfter: noteDraft.pollExpiredAfter,
|
||||
} : undefined,
|
||||
} : {} ),
|
||||
});
|
||||
|
||||
|
|
|
@ -21,7 +21,18 @@ import type { OnModuleInit } from '@nestjs/common';
|
|||
import type { UserEntityService } from './UserEntityService.js';
|
||||
import type { NoteEntityService } from './NoteEntityService.js';
|
||||
|
||||
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded'] as (typeof groupedNotificationTypes[number])[]);
|
||||
const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set([
|
||||
'note',
|
||||
'mention',
|
||||
'reply',
|
||||
'renote',
|
||||
'renote:grouped',
|
||||
'quote',
|
||||
'reaction',
|
||||
'reaction:grouped',
|
||||
'pollEnded',
|
||||
'scheduledNotePosted',
|
||||
] as (typeof groupedNotificationTypes[number])[]);
|
||||
|
||||
@Injectable()
|
||||
export class NotificationEntityService implements OnModuleInit {
|
||||
|
|
|
@ -54,10 +54,17 @@ export class MiAd {
|
|||
length: 8192, nullable: false,
|
||||
})
|
||||
public memo: string;
|
||||
|
||||
@Column('integer', {
|
||||
default: 0, nullable: false,
|
||||
})
|
||||
public dayOfWeek: number;
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public isSensitive: boolean;
|
||||
|
||||
constructor(data: Partial<MiAd>) {
|
||||
if (data == null) return;
|
||||
|
||||
|
|
|
@ -126,7 +126,7 @@ export class MiNoteDraft {
|
|||
@JoinColumn()
|
||||
public channel: MiChannel | null;
|
||||
|
||||
// 以下、Pollについて追加
|
||||
//#region 以下、Pollについて追加
|
||||
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
|
@ -151,13 +151,15 @@ export class MiNoteDraft {
|
|||
})
|
||||
public pollExpiredAfter: number | null;
|
||||
|
||||
// ここまで追加
|
||||
//#endregion
|
||||
|
||||
constructor(data: Partial<MiNoteDraft>) {
|
||||
if (data == null) return;
|
||||
@Column('timestamp with time zone', {
|
||||
nullable: true,
|
||||
})
|
||||
public scheduledAt: Date | null;
|
||||
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
(this as any)[k] = v;
|
||||
}
|
||||
}
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
})
|
||||
public isActuallyScheduled: boolean;
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import { MiNote } from './Note.js';
|
|||
import { MiAccessToken } from './AccessToken.js';
|
||||
import { MiRole } from './Role.js';
|
||||
import { MiDriveFile } from './DriveFile.js';
|
||||
import { MiNoteDraft } from './NoteDraft.js';
|
||||
|
||||
// misskey-js の notificationTypes と同期すべし
|
||||
export type MiNotification = {
|
||||
|
@ -60,6 +61,16 @@ export type MiNotification = {
|
|||
createdAt: string;
|
||||
notifierId: MiUser['id'];
|
||||
noteId: MiNote['id'];
|
||||
} | {
|
||||
type: 'scheduledNotePosted';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
noteId: MiNote['id'];
|
||||
} | {
|
||||
type: 'scheduledNotePostFailed';
|
||||
id: string;
|
||||
createdAt: string;
|
||||
noteDraftId: MiNoteDraft['id'];
|
||||
} | {
|
||||
type: 'receiveFollowRequest';
|
||||
id: string;
|
||||
|
|
|
@ -60,5 +60,10 @@ export const packedAdSchema = {
|
|||
optional: false,
|
||||
nullable: false,
|
||||
},
|
||||
isSensitive: {
|
||||
type: 'boolean',
|
||||
optional: false,
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
|
@ -195,6 +195,10 @@ export const packedMetaLiteSchema = {
|
|||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isSensitive: {
|
||||
type: 'boolean',
|
||||
optional: true, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -23,7 +23,7 @@ export const packedNoteDraftSchema = {
|
|||
},
|
||||
cw: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
|
@ -37,27 +37,23 @@ export const packedNoteDraftSchema = {
|
|||
},
|
||||
replyId: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
optional: false, nullable: true,
|
||||
format: 'id',
|
||||
example: 'xxxxxxxxxx',
|
||||
},
|
||||
renoteId: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
optional: false, nullable: true,
|
||||
format: 'id',
|
||||
example: 'xxxxxxxxxx',
|
||||
},
|
||||
reply: {
|
||||
type: 'object',
|
||||
optional: true, nullable: true,
|
||||
ref: 'Note',
|
||||
description: 'The reply target note contents if exists. If the reply target has been deleted since the draft was created, this will be null while replyId is not null.',
|
||||
},
|
||||
renote: {
|
||||
type: 'object',
|
||||
optional: true, nullable: true,
|
||||
ref: 'Note',
|
||||
description: 'The renote target note contents if exists. If the renote target has been deleted since the draft was created, this will be null while renoteId is not null.',
|
||||
},
|
||||
visibility: {
|
||||
type: 'string',
|
||||
|
@ -66,7 +62,7 @@ export const packedNoteDraftSchema = {
|
|||
},
|
||||
visibleUserIds: {
|
||||
type: 'array',
|
||||
optional: true, nullable: false,
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
|
@ -75,7 +71,7 @@ export const packedNoteDraftSchema = {
|
|||
},
|
||||
fileIds: {
|
||||
type: 'array',
|
||||
optional: true, nullable: false,
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
|
@ -93,11 +89,11 @@ export const packedNoteDraftSchema = {
|
|||
},
|
||||
hashtag: {
|
||||
type: 'string',
|
||||
optional: true, nullable: false,
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
poll: {
|
||||
type: 'object',
|
||||
optional: true, nullable: true,
|
||||
optional: false, nullable: true,
|
||||
properties: {
|
||||
expiresAt: {
|
||||
type: 'string',
|
||||
|
@ -124,9 +120,8 @@ export const packedNoteDraftSchema = {
|
|||
},
|
||||
channelId: {
|
||||
type: 'string',
|
||||
optional: true, nullable: true,
|
||||
optional: false, nullable: true,
|
||||
format: 'id',
|
||||
example: 'xxxxxxxxxx',
|
||||
},
|
||||
channel: {
|
||||
type: 'object',
|
||||
|
@ -160,12 +155,20 @@ export const packedNoteDraftSchema = {
|
|||
},
|
||||
localOnly: {
|
||||
type: 'boolean',
|
||||
optional: true, nullable: false,
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
reactionAcceptance: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
enum: ['likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote', null],
|
||||
},
|
||||
scheduledAt: {
|
||||
type: 'number',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
isActuallyScheduled: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
|
@ -207,6 +207,36 @@ export const packedNotificationSchema = {
|
|||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...baseSchema.properties,
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ['scheduledNotePosted'],
|
||||
},
|
||||
note: {
|
||||
type: 'object',
|
||||
ref: 'Note',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
...baseSchema.properties,
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ['scheduledNotePostFailed'],
|
||||
},
|
||||
noteDraft: {
|
||||
type: 'object',
|
||||
ref: 'NoteDraft',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
}, {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
|
|
@ -317,6 +317,10 @@ export const packedRolePoliciesSchema = {
|
|||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
scheduledNoteLimit: {
|
||||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
watermarkAvailable: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
|
|
|
@ -609,6 +609,8 @@ export const packedMeDetailedOnlySchema = {
|
|||
quote: { optional: true, ...notificationRecieveConfig },
|
||||
reaction: { optional: true, ...notificationRecieveConfig },
|
||||
pollEnded: { optional: true, ...notificationRecieveConfig },
|
||||
scheduledNotePosted: { optional: true, ...notificationRecieveConfig },
|
||||
scheduledNotePostFailed: { optional: true, ...notificationRecieveConfig },
|
||||
receiveFollowRequest: { optional: true, ...notificationRecieveConfig },
|
||||
followRequestAccepted: { optional: true, ...notificationRecieveConfig },
|
||||
roleAssigned: { optional: true, ...notificationRecieveConfig },
|
||||
|
|
|
@ -10,6 +10,7 @@ import { QueueLoggerService } from './QueueLoggerService.js';
|
|||
import { QueueProcessorService } from './QueueProcessorService.js';
|
||||
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
|
||||
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
|
||||
import { PostScheduledNoteProcessorService } from './processors/PostScheduledNoteProcessorService.js';
|
||||
import { InboxProcessorService } from './processors/InboxProcessorService.js';
|
||||
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
|
||||
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
|
||||
|
@ -79,6 +80,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor
|
|||
UserWebhookDeliverProcessorService,
|
||||
SystemWebhookDeliverProcessorService,
|
||||
EndedPollNotificationProcessorService,
|
||||
PostScheduledNoteProcessorService,
|
||||
DeliverProcessorService,
|
||||
InboxProcessorService,
|
||||
AggregateRetentionProcessorService,
|
||||
|
|
|
@ -14,6 +14,7 @@ import { CheckModeratorsActivityProcessorService } from '@/queue/processors/Chec
|
|||
import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js';
|
||||
import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js';
|
||||
import { EndedPollNotificationProcessorService } from './processors/EndedPollNotificationProcessorService.js';
|
||||
import { PostScheduledNoteProcessorService } from './processors/PostScheduledNoteProcessorService.js';
|
||||
import { DeliverProcessorService } from './processors/DeliverProcessorService.js';
|
||||
import { InboxProcessorService } from './processors/InboxProcessorService.js';
|
||||
import { DeleteDriveFilesProcessorService } from './processors/DeleteDriveFilesProcessorService.js';
|
||||
|
@ -85,6 +86,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
|||
private relationshipQueueWorker: Bull.Worker;
|
||||
private objectStorageQueueWorker: Bull.Worker;
|
||||
private endedPollNotificationQueueWorker: Bull.Worker;
|
||||
private postScheduledNoteQueueWorker: Bull.Worker;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
|
@ -94,6 +96,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
|||
private userWebhookDeliverProcessorService: UserWebhookDeliverProcessorService,
|
||||
private systemWebhookDeliverProcessorService: SystemWebhookDeliverProcessorService,
|
||||
private endedPollNotificationProcessorService: EndedPollNotificationProcessorService,
|
||||
private postScheduledNoteProcessorService: PostScheduledNoteProcessorService,
|
||||
private deliverProcessorService: DeliverProcessorService,
|
||||
private inboxProcessorService: InboxProcessorService,
|
||||
private deleteDriveFilesProcessorService: DeleteDriveFilesProcessorService,
|
||||
|
@ -520,6 +523,21 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
|||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region post scheduled note
|
||||
{
|
||||
this.postScheduledNoteQueueWorker = new Bull.Worker(QUEUE.POST_SCHEDULED_NOTE, async (job) => {
|
||||
if (this.config.sentryForBackend) {
|
||||
return Sentry.startSpan({ name: 'Queue: PostScheduledNote' }, () => this.postScheduledNoteProcessorService.process(job));
|
||||
} else {
|
||||
return this.postScheduledNoteProcessorService.process(job);
|
||||
}
|
||||
}, {
|
||||
...baseWorkerOptions(this.config, QUEUE.POST_SCHEDULED_NOTE),
|
||||
autorun: false,
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
@ -534,6 +552,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
|||
this.relationshipQueueWorker.run(),
|
||||
this.objectStorageQueueWorker.run(),
|
||||
this.endedPollNotificationQueueWorker.run(),
|
||||
this.postScheduledNoteQueueWorker.run(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
@ -549,6 +568,7 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
|||
this.relationshipQueueWorker.close(),
|
||||
this.objectStorageQueueWorker.close(),
|
||||
this.endedPollNotificationQueueWorker.close(),
|
||||
this.postScheduledNoteQueueWorker.close(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ export const QUEUE = {
|
|||
INBOX: 'inbox',
|
||||
SYSTEM: 'system',
|
||||
ENDED_POLL_NOTIFICATION: 'endedPollNotification',
|
||||
POST_SCHEDULED_NOTE: 'postScheduledNote',
|
||||
DB: 'db',
|
||||
RELATIONSHIP: 'relationship',
|
||||
OBJECT_STORAGE: 'objectStorage',
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { NoteDraftsRepository } from '@/models/_.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { NoteCreateService } from '@/core/NoteCreateService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { PostScheduledNoteJobData } from '../types.js';
|
||||
|
||||
@Injectable()
|
||||
export class PostScheduledNoteProcessorService {
|
||||
private logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.noteDraftsRepository)
|
||||
private noteDraftsRepository: NoteDraftsRepository,
|
||||
|
||||
private noteCreateService: NoteCreateService,
|
||||
private notificationService: NotificationService,
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('post-scheduled-note');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<PostScheduledNoteJobData>): Promise<void> {
|
||||
const draft = await this.noteDraftsRepository.findOne({ where: { id: job.data.noteDraftId }, relations: ['user'] });
|
||||
if (draft == null || draft.user == null || draft.scheduledAt == null || !draft.isActuallyScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const note = await this.noteCreateService.fetchAndCreate(draft.user, {
|
||||
createdAt: new Date(),
|
||||
fileIds: draft.fileIds,
|
||||
poll: draft.hasPoll ? {
|
||||
choices: draft.pollChoices,
|
||||
multiple: draft.pollMultiple,
|
||||
expiresAt: draft.pollExpiredAfter ? new Date(Date.now() + draft.pollExpiredAfter) : draft.pollExpiresAt ? new Date(draft.pollExpiresAt) : null,
|
||||
} : null,
|
||||
text: draft.text ?? null,
|
||||
replyId: draft.replyId,
|
||||
renoteId: draft.renoteId,
|
||||
cw: draft.cw,
|
||||
localOnly: draft.localOnly,
|
||||
reactionAcceptance: draft.reactionAcceptance,
|
||||
visibility: draft.visibility,
|
||||
visibleUserIds: draft.visibleUserIds,
|
||||
channelId: draft.channelId,
|
||||
});
|
||||
|
||||
// await不要
|
||||
this.noteDraftsRepository.remove(draft);
|
||||
|
||||
// await不要
|
||||
this.notificationService.createNotification(draft.userId, 'scheduledNotePosted', {
|
||||
noteId: note.id,
|
||||
});
|
||||
} catch (err) {
|
||||
this.notificationService.createNotification(draft.userId, 'scheduledNotePostFailed', {
|
||||
noteDraftId: draft.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -109,6 +109,10 @@ export type EndedPollNotificationJobData = {
|
|||
noteId: MiNote['id'];
|
||||
};
|
||||
|
||||
export type PostScheduledNoteJobData = {
|
||||
noteDraftId: string;
|
||||
};
|
||||
|
||||
export type SystemWebhookDeliverJobData<T extends SystemWebhookEventType = SystemWebhookEventType> = {
|
||||
type: T;
|
||||
content: SystemWebhookPayload<T>;
|
||||
|
|
|
@ -75,7 +75,7 @@ export class ServerService implements OnApplicationShutdown {
|
|||
@bindThis
|
||||
public async launch(): Promise<void> {
|
||||
const fastify = Fastify({
|
||||
trustProxy: true,
|
||||
trustProxy: this.config.trustProxy ?? true,
|
||||
logger: false,
|
||||
});
|
||||
this.#fastify = fastify;
|
||||
|
|
|
@ -36,6 +36,7 @@ export const paramDef = {
|
|||
startsAt: { type: 'integer' },
|
||||
imageUrl: { type: 'string', minLength: 1 },
|
||||
dayOfWeek: { type: 'integer' },
|
||||
isSensitive: { type: 'boolean' },
|
||||
},
|
||||
required: ['url', 'memo', 'place', 'priority', 'ratio', 'expiresAt', 'startsAt', 'imageUrl', 'dayOfWeek'],
|
||||
} as const;
|
||||
|
@ -55,6 +56,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
expiresAt: new Date(ps.expiresAt),
|
||||
startsAt: new Date(ps.startsAt),
|
||||
dayOfWeek: ps.dayOfWeek,
|
||||
isSensitive: ps.isSensitive,
|
||||
url: ps.url,
|
||||
imageUrl: ps.imageUrl,
|
||||
priority: ps.priority,
|
||||
|
@ -73,6 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
expiresAt: ad.expiresAt.toISOString(),
|
||||
startsAt: ad.startsAt.toISOString(),
|
||||
dayOfWeek: ad.dayOfWeek,
|
||||
isSensitive: ad.isSensitive,
|
||||
url: ad.url,
|
||||
imageUrl: ad.imageUrl,
|
||||
priority: ad.priority,
|
||||
|
|
|
@ -63,6 +63,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
expiresAt: ad.expiresAt.toISOString(),
|
||||
startsAt: ad.startsAt.toISOString(),
|
||||
dayOfWeek: ad.dayOfWeek,
|
||||
isSensitive: ad.isSensitive,
|
||||
url: ad.url,
|
||||
imageUrl: ad.imageUrl,
|
||||
memo: ad.memo,
|
||||
|
|
|
@ -39,6 +39,7 @@ export const paramDef = {
|
|||
expiresAt: { type: 'integer' },
|
||||
startsAt: { type: 'integer' },
|
||||
dayOfWeek: { type: 'integer' },
|
||||
isSensitive: { type: 'boolean' },
|
||||
},
|
||||
required: ['id'],
|
||||
} as const;
|
||||
|
@ -66,6 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : undefined,
|
||||
startsAt: ps.startsAt ? new Date(ps.startsAt) : undefined,
|
||||
dayOfWeek: ps.dayOfWeek,
|
||||
isSensitive: ps.isSensitive,
|
||||
});
|
||||
|
||||
const updatedAd = await this.adsRepository.findOneByOrFail({ id: ad.id });
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js';
|
||||
import type { DbQueue, DeliverQueue, EndedPollNotificationQueue, PostScheduledNoteQueue, InboxQueue, ObjectStorageQueue, SystemQueue, UserWebhookDeliverQueue, SystemWebhookDeliverQueue } from '@/core/QueueModule.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
@ -49,6 +49,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
constructor(
|
||||
@Inject('queue:system') public systemQueue: SystemQueue,
|
||||
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
|
||||
@Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue,
|
||||
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
|
||||
@Inject('queue:inbox') public inboxQueue: InboxQueue,
|
||||
@Inject('queue:db') public dbQueue: DbQueue,
|
||||
|
|
|
@ -103,6 +103,8 @@ export const meta = {
|
|||
quote: { optional: true, ...notificationRecieveConfig },
|
||||
reaction: { optional: true, ...notificationRecieveConfig },
|
||||
pollEnded: { optional: true, ...notificationRecieveConfig },
|
||||
scheduledNotePosted: { optional: true, ...notificationRecieveConfig },
|
||||
scheduledNotePostFailed: { optional: true, ...notificationRecieveConfig },
|
||||
receiveFollowRequest: { optional: true, ...notificationRecieveConfig },
|
||||
followRequestAccepted: { optional: true, ...notificationRecieveConfig },
|
||||
roleAssigned: { optional: true, ...notificationRecieveConfig },
|
||||
|
|
|
@ -18,9 +18,9 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
|||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['federation'],
|
||||
|
|
|
@ -209,6 +209,8 @@ export const paramDef = {
|
|||
quote: notificationRecieveConfig,
|
||||
reaction: notificationRecieveConfig,
|
||||
pollEnded: notificationRecieveConfig,
|
||||
scheduledNotePosted: notificationRecieveConfig,
|
||||
scheduledNotePostFailed: notificationRecieveConfig,
|
||||
receiveFollowRequest: notificationRecieveConfig,
|
||||
followRequestAccepted: notificationRecieveConfig,
|
||||
roleAssigned: notificationRecieveConfig,
|
||||
|
|
|
@ -6,17 +6,10 @@
|
|||
import ms from 'ms';
|
||||
import { In } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { UsersRepository, NotesRepository, BlockingsRepository, DriveFilesRepository, ChannelsRepository } from '@/models/_.js';
|
||||
import type { MiDriveFile } from '@/models/DriveFile.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import type { MiChannel } from '@/models/Channel.js';
|
||||
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||
import { NoteCreateService } from '@/core/NoteCreateService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { isQuote, isRenote } from '@/misc/is-renote.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
|
@ -223,168 +216,28 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
|
||||
private noteEntityService: NoteEntityService,
|
||||
private noteCreateService: NoteCreateService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
let visibleUsers: MiUser[] = [];
|
||||
if (ps.visibleUserIds) {
|
||||
visibleUsers = await this.usersRepository.findBy({
|
||||
id: In(ps.visibleUserIds),
|
||||
});
|
||||
}
|
||||
|
||||
let files: MiDriveFile[] = [];
|
||||
const fileIds = ps.fileIds ?? ps.mediaIds ?? null;
|
||||
if (fileIds != null) {
|
||||
files = await this.driveFilesRepository.createQueryBuilder('file')
|
||||
.where('file.userId = :userId AND file.id IN (:...fileIds)', {
|
||||
userId: me.id,
|
||||
fileIds,
|
||||
})
|
||||
.orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
|
||||
.setParameters({ fileIds })
|
||||
.getMany();
|
||||
|
||||
if (files.length !== fileIds.length) {
|
||||
throw new ApiError(meta.errors.noSuchFile);
|
||||
}
|
||||
}
|
||||
|
||||
let renote: MiNote | null = null;
|
||||
if (ps.renoteId != null) {
|
||||
// Fetch renote to note
|
||||
renote = await this.notesRepository.findOne({
|
||||
where: { id: ps.renoteId },
|
||||
relations: ['user', 'renote', 'reply'],
|
||||
});
|
||||
|
||||
if (renote == null) {
|
||||
throw new ApiError(meta.errors.noSuchRenoteTarget);
|
||||
} else if (isRenote(renote) && !isQuote(renote)) {
|
||||
throw new ApiError(meta.errors.cannotReRenote);
|
||||
}
|
||||
|
||||
// Check blocking
|
||||
if (renote.userId !== me.id) {
|
||||
const blockExist = await this.blockingsRepository.exists({
|
||||
where: {
|
||||
blockerId: renote.userId,
|
||||
blockeeId: me.id,
|
||||
},
|
||||
});
|
||||
if (blockExist) {
|
||||
throw new ApiError(meta.errors.youHaveBeenBlocked);
|
||||
}
|
||||
}
|
||||
|
||||
if (renote.visibility === 'followers' && renote.userId !== me.id) {
|
||||
// 他人のfollowers noteはreject
|
||||
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
|
||||
} else if (renote.visibility === 'specified') {
|
||||
// specified / direct noteはreject
|
||||
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
|
||||
}
|
||||
|
||||
if (renote.channelId && renote.channelId !== ps.channelId) {
|
||||
// チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック
|
||||
// リノートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
|
||||
const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId });
|
||||
if (renoteChannel == null) {
|
||||
// リノートしたいノートが書き込まれているチャンネルが無い
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
} else if (!renoteChannel.allowRenoteToExternal) {
|
||||
// リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合
|
||||
throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let reply: MiNote | null = null;
|
||||
if (ps.replyId != null) {
|
||||
// Fetch reply
|
||||
reply = await this.notesRepository.findOne({
|
||||
where: { id: ps.replyId },
|
||||
relations: ['user'],
|
||||
});
|
||||
|
||||
if (reply == null) {
|
||||
throw new ApiError(meta.errors.noSuchReplyTarget);
|
||||
} else if (isRenote(reply) && !isQuote(reply)) {
|
||||
throw new ApiError(meta.errors.cannotReplyToPureRenote);
|
||||
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) {
|
||||
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
|
||||
} else if (reply.visibility === 'specified' && ps.visibility !== 'specified') {
|
||||
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
|
||||
}
|
||||
|
||||
// Check blocking
|
||||
if (reply.userId !== me.id) {
|
||||
const blockExist = await this.blockingsRepository.exists({
|
||||
where: {
|
||||
blockerId: reply.userId,
|
||||
blockeeId: me.id,
|
||||
},
|
||||
});
|
||||
if (blockExist) {
|
||||
throw new ApiError(meta.errors.youHaveBeenBlocked);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ps.poll) {
|
||||
if (typeof ps.poll.expiresAt === 'number') {
|
||||
if (ps.poll.expiresAt < Date.now()) {
|
||||
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
|
||||
}
|
||||
} else if (typeof ps.poll.expiredAfter === 'number') {
|
||||
ps.poll.expiresAt = Date.now() + ps.poll.expiredAfter;
|
||||
}
|
||||
}
|
||||
|
||||
let channel: MiChannel | null = null;
|
||||
if (ps.channelId != null) {
|
||||
channel = await this.channelsRepository.findOneBy({ id: ps.channelId, isArchived: false });
|
||||
|
||||
if (channel == null) {
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
}
|
||||
|
||||
// 投稿を作成
|
||||
try {
|
||||
const note = await this.noteCreateService.create(me, {
|
||||
const note = await this.noteCreateService.fetchAndCreate(me, {
|
||||
createdAt: new Date(),
|
||||
files: files,
|
||||
fileIds: ps.fileIds ?? ps.mediaIds ?? [],
|
||||
poll: ps.poll ? {
|
||||
choices: ps.poll.choices,
|
||||
multiple: ps.poll.multiple ?? false,
|
||||
expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
|
||||
} : undefined,
|
||||
text: ps.text ?? undefined,
|
||||
reply,
|
||||
renote,
|
||||
cw: ps.cw,
|
||||
expiresAt: ps.poll.expiredAfter ? new Date(Date.now() + ps.poll.expiredAfter) : ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
|
||||
} : null,
|
||||
text: ps.text ?? null,
|
||||
replyId: ps.replyId ?? null,
|
||||
renoteId: ps.renoteId ?? null,
|
||||
cw: ps.cw ?? null,
|
||||
localOnly: ps.localOnly,
|
||||
reactionAcceptance: ps.reactionAcceptance,
|
||||
visibility: ps.visibility,
|
||||
visibleUsers,
|
||||
channel,
|
||||
visibleUserIds: ps.visibleUserIds ?? [],
|
||||
channelId: ps.channelId ?? null,
|
||||
apMentions: ps.noExtractMentions ? [] : undefined,
|
||||
apHashtags: ps.noExtractHashtags ? [] : undefined,
|
||||
apEmojis: ps.noExtractEmojis ? [] : undefined,
|
||||
|
@ -393,16 +246,46 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
return {
|
||||
createdNote: await this.noteEntityService.pack(note, me),
|
||||
};
|
||||
} catch (e) {
|
||||
} catch (err) {
|
||||
// TODO: 他のErrorもここでキャッチしてエラーメッセージを当てるようにしたい
|
||||
if (e instanceof IdentifiableError) {
|
||||
if (e.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
|
||||
if (err instanceof IdentifiableError) {
|
||||
if (err.id === '689ee33f-f97c-479a-ac49-1b9f8140af99') {
|
||||
throw new ApiError(meta.errors.containsProhibitedWords);
|
||||
} else if (e.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
|
||||
} else if (err.id === '9f466dab-c856-48cd-9e65-ff90ff750580') {
|
||||
throw new ApiError(meta.errors.containsTooManyMentions);
|
||||
} else if (err.id === '801c046c-5bf5-4234-ad2b-e78fc20a2ac7') {
|
||||
throw new ApiError(meta.errors.noSuchFile);
|
||||
} else if (err.id === '53983c56-e163-45a6-942f-4ddc485d4290') {
|
||||
throw new ApiError(meta.errors.noSuchRenoteTarget);
|
||||
} else if (err.id === 'bde24c37-121f-4e7d-980d-cec52f599f02') {
|
||||
throw new ApiError(meta.errors.cannotReRenote);
|
||||
} else if (err.id === '2b4fe776-4414-4a2d-ae39-f3418b8fd4d3') {
|
||||
throw new ApiError(meta.errors.youHaveBeenBlocked);
|
||||
} else if (err.id === '90b9d6f0-893a-4fef-b0f1-e9a33989f71a') {
|
||||
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
|
||||
} else if (err.id === '48d7a997-da5c-4716-b3c3-92db3f37bf7d') {
|
||||
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
|
||||
} else if (err.id === 'b060f9a6-8909-4080-9e0b-94d9fa6f6a77') {
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
} else if (err.id === '7e435f4a-780d-4cfc-a15a-42519bd6fb67') {
|
||||
throw new ApiError(meta.errors.cannotRenoteOutsideOfChannel);
|
||||
} else if (err.id === '60142edb-1519-408e-926d-4f108d27bee0') {
|
||||
throw new ApiError(meta.errors.noSuchReplyTarget);
|
||||
} else if (err.id === 'f089e4e2-c0e7-4f60-8a23-e5a6bf786b36') {
|
||||
throw new ApiError(meta.errors.cannotReplyToPureRenote);
|
||||
} else if (err.id === '11cd37b3-a411-4f77-8633-c580ce6a8dce') {
|
||||
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
|
||||
} else if (err.id === 'ced780a1-2012-4caf-bc7e-a95a291294cb') {
|
||||
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
|
||||
} else if (err.id === 'b0df6025-f2e8-44b4-a26a-17ad99104612') {
|
||||
throw new ApiError(meta.errors.youHaveBeenBlocked);
|
||||
} else if (err.id === '0c11c11e-0c8d-48e7-822c-76ccef660068') {
|
||||
throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll);
|
||||
} else if (err.id === 'bfa3905b-25f5-4894-b430-da331a490e4b') {
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -124,6 +124,12 @@ export const meta = {
|
|||
id: '9ee33bbe-fde3-4c71-9b51-e50492c6b9c8',
|
||||
},
|
||||
|
||||
tooManyScheduledNotes: {
|
||||
message: 'You cannot create scheduled notes any more.',
|
||||
code: 'TOO_MANY_SCHEDULED_NOTES',
|
||||
id: '22ae69eb-09e3-4541-a850-773cfa45e693',
|
||||
},
|
||||
|
||||
cannotRenoteToExternal: {
|
||||
message: 'Cannot Renote to External.',
|
||||
code: 'CANNOT_RENOTE_TO_EXTERNAL',
|
||||
|
@ -162,7 +168,7 @@ export const paramDef = {
|
|||
fileIds: {
|
||||
type: 'array',
|
||||
uniqueItems: true,
|
||||
minItems: 1,
|
||||
minItems: 0,
|
||||
maxItems: 16,
|
||||
items: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
|
@ -183,8 +189,10 @@ export const paramDef = {
|
|||
},
|
||||
required: ['choices'],
|
||||
},
|
||||
scheduledAt: { type: 'integer', nullable: true },
|
||||
isActuallyScheduled: { type: 'boolean', default: false },
|
||||
},
|
||||
required: [],
|
||||
required: ['visibility', 'visibleUserIds', 'cw', 'hashtag', 'localOnly', 'reactionAcceptance', 'replyId', 'renoteId', 'channelId', 'text', 'fileIds', 'poll', 'scheduledAt', 'isActuallyScheduled'],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
|
@ -196,22 +204,23 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
super(meta, paramDef, async (ps, me) => {
|
||||
const draft = await this.noteDraftService.create(me, {
|
||||
fileIds: ps.fileIds,
|
||||
poll: ps.poll ? {
|
||||
choices: ps.poll.choices,
|
||||
multiple: ps.poll.multiple ?? false,
|
||||
expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
|
||||
expiredAfter: ps.poll.expiredAfter ?? null,
|
||||
} : undefined,
|
||||
text: ps.text ?? null,
|
||||
replyId: ps.replyId ?? undefined,
|
||||
renoteId: ps.renoteId ?? undefined,
|
||||
cw: ps.cw ?? null,
|
||||
...(ps.hashtag ? { hashtag: ps.hashtag } : {}),
|
||||
pollChoices: ps.poll?.choices ?? [],
|
||||
pollMultiple: ps.poll?.multiple ?? false,
|
||||
pollExpiresAt: ps.poll?.expiresAt ? new Date(ps.poll.expiresAt) : null,
|
||||
pollExpiredAfter: ps.poll?.expiredAfter ?? null,
|
||||
hasPoll: ps.poll != null,
|
||||
text: ps.text,
|
||||
replyId: ps.replyId,
|
||||
renoteId: ps.renoteId,
|
||||
cw: ps.cw,
|
||||
hashtag: ps.hashtag,
|
||||
localOnly: ps.localOnly,
|
||||
reactionAcceptance: ps.reactionAcceptance,
|
||||
visibility: ps.visibility,
|
||||
visibleUserIds: ps.visibleUserIds ?? [],
|
||||
channelId: ps.channelId ?? undefined,
|
||||
visibleUserIds: ps.visibleUserIds,
|
||||
channelId: ps.channelId,
|
||||
scheduledAt: ps.scheduledAt ? new Date(ps.scheduledAt) : null,
|
||||
isActuallyScheduled: ps.isActuallyScheduled,
|
||||
}).catch((err) => {
|
||||
if (err instanceof IdentifiableError) {
|
||||
switch (err.id) {
|
||||
|
@ -241,6 +250,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.cannotReplyToInvisibleNote);
|
||||
case '215dbc76-336c-4d2a-9605-95766ba7dab0':
|
||||
throw new ApiError(meta.errors.cannotReplyToSpecifiedVisibilityNoteWithExtendedVisibility);
|
||||
case 'c3275f19-4558-4c59-83e1-4f684b5fab66':
|
||||
throw new ApiError(meta.errors.tooManyScheduledNotes);
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
|
|
|
@ -41,6 +41,7 @@ export const paramDef = {
|
|||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
sinceDate: { type: 'integer' },
|
||||
untilDate: { type: 'integer' },
|
||||
scheduled: { type: 'boolean', nullable: true },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
@ -58,6 +59,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const query = this.queryService.makePaginationQuery<MiNoteDraft>(this.noteDraftsRepository.createQueryBuilder('drafts'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('drafts.userId = :meId', { meId: me.id });
|
||||
|
||||
if (ps.scheduled === true) {
|
||||
query.andWhere('drafts.isActuallyScheduled = true');
|
||||
} else if (ps.scheduled === false) {
|
||||
query.andWhere('drafts.isActuallyScheduled = false');
|
||||
}
|
||||
|
||||
const drafts = await query
|
||||
.limit(ps.limit)
|
||||
.getMany();
|
||||
|
|
|
@ -159,6 +159,12 @@ export const meta = {
|
|||
code: 'CANNOT_REPLY_TO_SPECIFIED_VISIBILITY_NOTE_WITH_EXTENDED_VISIBILITY',
|
||||
id: '215dbc76-336c-4d2a-9605-95766ba7dab0',
|
||||
},
|
||||
|
||||
tooManyScheduledNotes: {
|
||||
message: 'You cannot create scheduled notes any more.',
|
||||
code: 'TOO_MANY_SCHEDULED_NOTES',
|
||||
id: '02f5df79-08ae-4a33-8524-f1503c8f6212',
|
||||
},
|
||||
},
|
||||
|
||||
limit: {
|
||||
|
@ -171,14 +177,14 @@ export const paramDef = {
|
|||
type: 'object',
|
||||
properties: {
|
||||
draftId: { type: 'string', nullable: false, format: 'misskey:id' },
|
||||
visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' },
|
||||
visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'] },
|
||||
visibleUserIds: { type: 'array', uniqueItems: true, items: {
|
||||
type: 'string', format: 'misskey:id',
|
||||
} },
|
||||
cw: { type: 'string', nullable: true, minLength: 1, maxLength: 100 },
|
||||
hashtag: { type: 'string', nullable: true, maxLength: 200 },
|
||||
localOnly: { type: 'boolean', default: false },
|
||||
reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
|
||||
localOnly: { type: 'boolean' },
|
||||
reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'] },
|
||||
replyId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||
renoteId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||
channelId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||
|
@ -194,7 +200,7 @@ export const paramDef = {
|
|||
fileIds: {
|
||||
type: 'array',
|
||||
uniqueItems: true,
|
||||
minItems: 1,
|
||||
minItems: 0,
|
||||
maxItems: 16,
|
||||
items: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
|
@ -215,6 +221,8 @@ export const paramDef = {
|
|||
},
|
||||
required: ['choices'],
|
||||
},
|
||||
scheduledAt: { type: 'integer', nullable: true },
|
||||
isActuallyScheduled: { type: 'boolean' },
|
||||
},
|
||||
required: ['draftId'],
|
||||
} as const;
|
||||
|
@ -228,22 +236,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
super(meta, paramDef, async (ps, me) => {
|
||||
const draft = await this.noteDraftService.update(me, ps.draftId, {
|
||||
fileIds: ps.fileIds,
|
||||
poll: ps.poll ? {
|
||||
choices: ps.poll.choices,
|
||||
multiple: ps.poll.multiple ?? false,
|
||||
expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null,
|
||||
expiredAfter: ps.poll.expiredAfter ?? null,
|
||||
} : undefined,
|
||||
text: ps.text ?? null,
|
||||
replyId: ps.replyId ?? undefined,
|
||||
renoteId: ps.renoteId ?? undefined,
|
||||
cw: ps.cw ?? null,
|
||||
...(ps.hashtag ? { hashtag: ps.hashtag } : {}),
|
||||
pollChoices: ps.poll?.choices,
|
||||
pollMultiple: ps.poll?.multiple,
|
||||
pollExpiresAt: ps.poll?.expiresAt ? new Date(ps.poll.expiresAt) : null,
|
||||
pollExpiredAfter: ps.poll?.expiredAfter,
|
||||
text: ps.text,
|
||||
replyId: ps.replyId,
|
||||
renoteId: ps.renoteId,
|
||||
cw: ps.cw,
|
||||
hashtag: ps.hashtag,
|
||||
localOnly: ps.localOnly,
|
||||
reactionAcceptance: ps.reactionAcceptance,
|
||||
visibility: ps.visibility,
|
||||
visibleUserIds: ps.visibleUserIds ?? [],
|
||||
channelId: ps.channelId ?? undefined,
|
||||
visibleUserIds: ps.visibleUserIds,
|
||||
channelId: ps.channelId,
|
||||
scheduledAt: ps.scheduledAt ? new Date(ps.scheduledAt) : null,
|
||||
isActuallyScheduled: ps.isActuallyScheduled,
|
||||
}).catch((err) => {
|
||||
if (err instanceof IdentifiableError) {
|
||||
switch (err.id) {
|
||||
|
@ -285,6 +293,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.containsProhibitedWords);
|
||||
case '4de0363a-3046-481b-9b0f-feff3e211025':
|
||||
throw new ApiError(meta.errors.containsTooManyMentions);
|
||||
case 'bacdf856-5c51-4159-b88a-804fa5103be5':
|
||||
throw new ApiError(meta.errors.tooManyScheduledNotes);
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
|
|
|
@ -29,10 +29,16 @@ export const meta = {
|
|||
id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d',
|
||||
},
|
||||
|
||||
signinRequired: {
|
||||
message: 'Signin required.',
|
||||
code: 'SIGNIN_REQUIRED',
|
||||
id: '8e75455b-738c-471d-9f80-62693f33372e',
|
||||
contentRestrictedByUser: {
|
||||
message: 'Content restricted by user. Please sign in to view.',
|
||||
code: 'CONTENT_RESTRICTED_BY_USER',
|
||||
id: 'fbcc002d-37d9-4944-a6b0-d9e29f2d33ab',
|
||||
},
|
||||
|
||||
contentRestrictedByServer: {
|
||||
message: 'Content restricted by server settings. Please sign in to view.',
|
||||
code: 'CONTENT_RESTRICTED_BY_SERVER',
|
||||
id: '145f88d2-b03d-4087-8143-a78928883c4b',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
@ -61,15 +67,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
});
|
||||
|
||||
if (note.user!.requireSigninToViewContents && me == null) {
|
||||
throw new ApiError(meta.errors.signinRequired);
|
||||
throw new ApiError(meta.errors.contentRestrictedByUser);
|
||||
}
|
||||
|
||||
if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) {
|
||||
throw new ApiError(meta.errors.signinRequired);
|
||||
throw new ApiError(meta.errors.contentRestrictedByServer);
|
||||
}
|
||||
|
||||
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && note.userHost != null && me == null) {
|
||||
throw new ApiError(meta.errors.signinRequired);
|
||||
throw new ApiError(meta.errors.contentRestrictedByServer);
|
||||
}
|
||||
|
||||
return await this.noteEntityService.pack(note, me, {
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
};
|
||||
window.onunhandledrejection = (e) => {
|
||||
console.error(e);
|
||||
renderError('SOMETHING_HAPPENED_IN_PROMISE', e);
|
||||
renderError('SOMETHING_HAPPENED_IN_PROMISE', e.reason || e);
|
||||
};
|
||||
|
||||
let forceError = localStorage.getItem('forceError');
|
||||
|
|
|
@ -12,6 +12,8 @@
|
|||
* quote - 投稿が引用Renoteされた
|
||||
* reaction - 投稿にリアクションされた
|
||||
* pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した
|
||||
* scheduledNotePosted - 予約したノートが投稿された
|
||||
* scheduledNotePostFailed - 予約したノートの投稿に失敗した
|
||||
* receiveFollowRequest - フォローリクエストされた
|
||||
* followRequestAccepted - 自分の送ったフォローリクエストが承認された
|
||||
* roleAssigned - ロールが付与された
|
||||
|
@ -32,6 +34,8 @@ export const notificationTypes = [
|
|||
'quote',
|
||||
'reaction',
|
||||
'pollEnded',
|
||||
'scheduledNotePosted',
|
||||
'scheduledNotePostFailed',
|
||||
'receiveFollowRequest',
|
||||
'followRequestAccepted',
|
||||
'roleAssigned',
|
||||
|
|
|
@ -64,6 +64,8 @@ function toBase62(n: number): string {
|
|||
}
|
||||
|
||||
export function getConfig(): UserConfig {
|
||||
const localesHash = toBase62(hash(JSON.stringify(locales)));
|
||||
|
||||
return {
|
||||
base: '/embed_vite/',
|
||||
|
||||
|
@ -148,9 +150,9 @@ export function getConfig(): UserConfig {
|
|||
// dependencies of i18n.ts
|
||||
'config': ['@@/js/config.js'],
|
||||
},
|
||||
entryFileNames: 'scripts/[hash:8].js',
|
||||
chunkFileNames: 'scripts/[hash:8].js',
|
||||
assetFileNames: 'assets/[hash:8][extname]',
|
||||
entryFileNames: `scripts/${localesHash}-[hash:8].js`,
|
||||
chunkFileNames: `scripts/${localesHash}-[hash:8].js`,
|
||||
assetFileNames: `assets/${localesHash}-[hash:8][extname]`,
|
||||
paths(id) {
|
||||
for (const p of externalPackages) {
|
||||
if (p.match.test(id)) {
|
||||
|
|
|
@ -57,12 +57,15 @@
|
|||
"json5": "2.2.3",
|
||||
"magic-string": "0.30.18",
|
||||
"matter-js": "0.20.0",
|
||||
"mediabunny": "1.15.1",
|
||||
"mfm-js": "0.25.0",
|
||||
"misskey-bubble-game": "workspace:*",
|
||||
"misskey-js": "workspace:*",
|
||||
"misskey-reversi": "workspace:*",
|
||||
"photoswipe": "5.4.4",
|
||||
"punycode.js": "2.3.1",
|
||||
"qr-code-styling": "1.9.2",
|
||||
"qr-scanner": "1.4.2",
|
||||
"rollup": "4.50.1",
|
||||
"sanitize-html": "2.17.0",
|
||||
"sass": "1.92.1",
|
||||
|
|
|
@ -151,7 +151,21 @@ export async function common(createVue: () => Promise<App<Element>>) {
|
|||
}
|
||||
//#endregion
|
||||
|
||||
//#region Sync dark mode
|
||||
if (prefer.s.syncDeviceDarkMode) {
|
||||
store.set('darkMode', isDeviceDarkmode());
|
||||
}
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => {
|
||||
if (prefer.s.syncDeviceDarkMode) {
|
||||
store.set('darkMode', mql.matches);
|
||||
}
|
||||
});
|
||||
//#endregion
|
||||
|
||||
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
|
||||
// NOTE: この処理は必ずダークモード判定処理より後に来ること(初回のテーマ適用のため)
|
||||
// see: https://github.com/misskey-dev/misskey/issues/16562
|
||||
watch(store.r.darkMode, (darkMode) => {
|
||||
const theme = (() => {
|
||||
if (darkMode) {
|
||||
|
@ -183,18 +197,6 @@ export async function common(createVue: () => Promise<App<Element>>) {
|
|||
});
|
||||
}
|
||||
|
||||
//#region Sync dark mode
|
||||
if (prefer.s.syncDeviceDarkMode) {
|
||||
store.set('darkMode', isDeviceDarkmode());
|
||||
}
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => {
|
||||
if (prefer.s.syncDeviceDarkMode) {
|
||||
store.set('darkMode', mql.matches);
|
||||
}
|
||||
});
|
||||
//#endregion
|
||||
|
||||
if (!isSafeMode) {
|
||||
if (prefer.s.darkTheme && store.s.darkMode) {
|
||||
if (miLocalStorage.getItem('themeId') !== prefer.s.darkTheme.id) applyTheme(prefer.s.darkTheme);
|
||||
|
|
|
@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
import { initShaderProgram } from '@/utility/webgl.js';
|
||||
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
|
||||
|
@ -21,47 +22,6 @@ const props = withDefaults(defineProps<{
|
|||
focus: 1.0,
|
||||
});
|
||||
|
||||
function loadShader(gl: WebGLRenderingContext, type: number, source: string) {
|
||||
const shader = gl.createShader(type);
|
||||
if (shader == null) return null;
|
||||
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
alert(
|
||||
`falied to compile shader: ${gl.getShaderInfoLog(shader)}`,
|
||||
);
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shader;
|
||||
}
|
||||
|
||||
function initShaderProgram(gl: WebGLRenderingContext, vsSource: string, fsSource: string) {
|
||||
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
|
||||
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
|
||||
|
||||
const shaderProgram = gl.createProgram();
|
||||
if (vertexShader == null || fragmentShader == null) return null;
|
||||
|
||||
gl.attachShader(shaderProgram, vertexShader);
|
||||
gl.attachShader(shaderProgram, fragmentShader);
|
||||
gl.linkProgram(shaderProgram);
|
||||
|
||||
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
|
||||
alert(
|
||||
`failed to init shader: ${gl.getProgramInfoLog(
|
||||
shaderProgram,
|
||||
)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shaderProgram;
|
||||
}
|
||||
|
||||
let handle: ReturnType<typeof window['requestAnimationFrame']> | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
|
@ -71,7 +31,7 @@ onMounted(() => {
|
|||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const maybeGl = canvas.getContext('webgl', { premultipliedAlpha: true });
|
||||
const maybeGl = canvas.getContext('webgl2', { premultipliedAlpha: true });
|
||||
if (maybeGl == null) return;
|
||||
|
||||
const gl = maybeGl;
|
||||
|
@ -82,18 +42,16 @@ onMounted(() => {
|
|||
const positionBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
|
||||
const shaderProgram = initShaderProgram(gl, `
|
||||
attribute vec2 vertex;
|
||||
|
||||
const shaderProgram = initShaderProgram(gl, `#version 300 es
|
||||
in vec2 position;
|
||||
uniform vec2 u_scale;
|
||||
|
||||
varying vec2 v_pos;
|
||||
out vec2 in_uv;
|
||||
|
||||
void main() {
|
||||
gl_Position = vec4(vertex, 0.0, 1.0);
|
||||
v_pos = vertex / u_scale;
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
in_uv = position / u_scale;
|
||||
}
|
||||
`, `
|
||||
`, `#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
vec3 mod289(vec3 x) {
|
||||
|
@ -143,6 +101,7 @@ onMounted(() => {
|
|||
return 130.0 * dot(m, g);
|
||||
}
|
||||
|
||||
in vec2 in_uv;
|
||||
uniform float u_time;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_spread;
|
||||
|
@ -150,8 +109,7 @@ onMounted(() => {
|
|||
uniform float u_warp;
|
||||
uniform float u_focus;
|
||||
uniform float u_itensity;
|
||||
|
||||
varying vec2 v_pos;
|
||||
out vec4 out_color;
|
||||
|
||||
float circle( in vec2 _pos, in vec2 _origin, in float _radius ) {
|
||||
float SPREAD = 0.7 * u_spread;
|
||||
|
@ -182,13 +140,13 @@ onMounted(() => {
|
|||
|
||||
float ratio = u_resolution.x / u_resolution.y;
|
||||
|
||||
vec2 uv = vec2( v_pos.x, v_pos.y / ratio ) * 0.5 + 0.5;
|
||||
vec2 uv = vec2( in_uv.x, in_uv.y / ratio ) * 0.5 + 0.5;
|
||||
|
||||
vec3 color = vec3( 0.0 );
|
||||
|
||||
float greenMix = snoise( v_pos * 1.31 + u_time * 0.8 * 0.00017 ) * 0.5 + 0.5;
|
||||
float purpleMix = snoise( v_pos * 1.26 + u_time * 0.8 * -0.0001 ) * 0.5 + 0.5;
|
||||
float orangeMix = snoise( v_pos * 1.34 + u_time * 0.8 * 0.00015 ) * 0.5 + 0.5;
|
||||
float greenMix = snoise( in_uv * 1.31 + u_time * 0.8 * 0.00017 ) * 0.5 + 0.5;
|
||||
float purpleMix = snoise( in_uv * 1.26 + u_time * 0.8 * -0.0001 ) * 0.5 + 0.5;
|
||||
float orangeMix = snoise( in_uv * 1.34 + u_time * 0.8 * 0.00015 ) * 0.5 + 0.5;
|
||||
|
||||
float alphaOne = 0.35 + 0.65 * pow( snoise( vec2( u_time * 0.00012, uv.x ) ) * 0.5 + 0.5, 1.2 );
|
||||
float alphaTwo = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 1561.0 ) * 0.00014, uv.x ) ) * 0.5 + 0.5, 1.2 );
|
||||
|
@ -198,10 +156,10 @@ onMounted(() => {
|
|||
color += vec3( circle( uv, vec2( 0.90 + cos( u_time * 0.000166 ) * 0.06, 0.42 + sin( u_time * 0.000138 ) * 0.06 ), 0.18 ) ) * alphaTwo * ( green * greenMix + purple * purpleMix );
|
||||
color += vec3( circle( uv, vec2( 0.19 + sin( u_time * 0.000112 ) * 0.06, 0.25 + sin( u_time * 0.000192 ) * 0.06 ), 0.09 ) ) * alphaThree * ( orange * orangeMix );
|
||||
|
||||
color *= u_itensity + 1.0 * pow( snoise( vec2( v_pos.y + u_time * 0.00013, v_pos.x + u_time * -0.00009 ) ) * 0.5 + 0.5, 2.0 );
|
||||
color *= u_itensity + 1.0 * pow( snoise( vec2( in_uv.y + u_time * 0.00013, in_uv.x + u_time * -0.00009 ) ) * 0.5 + 0.5, 2.0 );
|
||||
|
||||
vec3 inverted = vec3( 1.0 ) - color;
|
||||
gl_FragColor = vec4( color, max(max(color.x, color.y), color.z) );
|
||||
out_color = vec4(color, max(max(color.x, color.y), color.z));
|
||||
}
|
||||
`);
|
||||
if (shaderProgram == null) return;
|
||||
|
@ -223,7 +181,7 @@ onMounted(() => {
|
|||
gl.uniform1f(u_itensity, 0.5);
|
||||
gl.uniform2fv(u_scale, [props.scale, props.scale]);
|
||||
|
||||
const vertex = gl.getAttribLocation(shaderProgram, 'vertex');
|
||||
const vertex = gl.getAttribLocation(shaderProgram, 'position');
|
||||
gl.enableVertexAttribArray(vertex);
|
||||
gl.vertexAttribPointer(vertex, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
|
|
|
@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
<article v-if="channel.description">
|
||||
<p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p>
|
||||
<Mfm :text="channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description" :plain="true"/>
|
||||
</article>
|
||||
<footer>
|
||||
<span v-if="channel.lastNotedAt">
|
||||
|
|
|
@ -530,6 +530,14 @@ defineExpose({
|
|||
--eachSize: 50px;
|
||||
}
|
||||
|
||||
&.s4 {
|
||||
--eachSize: 55px;
|
||||
}
|
||||
|
||||
&.s5 {
|
||||
--eachSize: 60px;
|
||||
}
|
||||
|
||||
&.w1 {
|
||||
width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr;
|
||||
|
|
|
@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</p>
|
||||
<footer>
|
||||
<img class="icon" :src="flash.user.avatarUrl"/>
|
||||
<p>{{ userName(flash.user) }}</p>
|
||||
<MkUserName class="name" :user="flash.user"/>
|
||||
</footer>
|
||||
</article>
|
||||
</MkA>
|
||||
|
@ -23,7 +23,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { userName } from '@/filters/user.js';
|
||||
|
||||
const props = defineProps<{
|
||||
flash: Misskey.entities.Flash;
|
||||
|
@ -80,7 +79,7 @@ const props = defineProps<{
|
|||
vertical-align: top;
|
||||
}
|
||||
|
||||
> p {
|
||||
> .name {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
font-size: 0.8em;
|
||||
|
|
|
@ -19,9 +19,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div :class="$style.root">
|
||||
<div :class="$style.container">
|
||||
<div :class="$style.preview">
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
||||
<canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown="onImagePointerdown"></canvas>
|
||||
<div :class="$style.previewContainer">
|
||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
||||
<div class="_acrylic" :class="$style.editControls">
|
||||
<button class="_button" :class="[$style.previewControlsButton, penMode != null ? $style.active : null]" @click="showPenMenu"><i class="ti ti-pencil"></i></button>
|
||||
</div>
|
||||
<div class="_acrylic" :class="$style.previewControls">
|
||||
<button class="_button" :class="[$style.previewControlsButton, !enabled ? $style.active : null]" @click="enabled = false">Before</button>
|
||||
<button class="_button" :class="[$style.previewControlsButton, enabled ? $style.active : null]" @click="enabled = true">After</button>
|
||||
|
@ -212,6 +215,147 @@ watch(enabled, () => {
|
|||
renderer.render();
|
||||
}
|
||||
});
|
||||
|
||||
const penMode = ref<'fill' | 'blur' | 'pixelate' | null>(null);
|
||||
|
||||
function showPenMenu(ev: MouseEvent) {
|
||||
os.popupMenu([{
|
||||
text: i18n.ts._imageEffector._fxs.fill,
|
||||
action: () => {
|
||||
penMode.value = 'fill';
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._imageEffector._fxs.blur,
|
||||
action: () => {
|
||||
penMode.value = 'blur';
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._imageEffector._fxs.pixelate,
|
||||
action: () => {
|
||||
penMode.value = 'pixelate';
|
||||
},
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
function onImagePointerdown(ev: PointerEvent) {
|
||||
if (canvasEl.value == null || imageBitmap == null || penMode.value == null) return;
|
||||
|
||||
const AW = canvasEl.value.clientWidth;
|
||||
const AH = canvasEl.value.clientHeight;
|
||||
const BW = imageBitmap.width;
|
||||
const BH = imageBitmap.height;
|
||||
|
||||
let xOffset = 0;
|
||||
let yOffset = 0;
|
||||
|
||||
if (AW / AH < BW / BH) { // 横長
|
||||
yOffset = AH - BH * (AW / BW);
|
||||
} else { // 縦長
|
||||
xOffset = AW - BW * (AH / BH);
|
||||
}
|
||||
|
||||
xOffset /= 2;
|
||||
yOffset /= 2;
|
||||
|
||||
let startX = ev.offsetX - xOffset;
|
||||
let startY = ev.offsetY - yOffset;
|
||||
|
||||
if (AW / AH < BW / BH) { // 横長
|
||||
startX = startX / (Math.max(AW, AH) / Math.max(BH / BW, 1));
|
||||
startY = startY / (Math.max(AW, AH) / Math.max(BW / BH, 1));
|
||||
} else { // 縦長
|
||||
startX = startX / (Math.min(AW, AH) / Math.max(BH / BW, 1));
|
||||
startY = startY / (Math.min(AW, AH) / Math.max(BW / BH, 1));
|
||||
}
|
||||
|
||||
const id = genId();
|
||||
if (penMode.value === 'fill') {
|
||||
layers.push({
|
||||
id,
|
||||
fxId: 'fill',
|
||||
params: {
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
scaleX: 0.1,
|
||||
scaleY: 0.1,
|
||||
angle: 0,
|
||||
opacity: 1,
|
||||
color: [1, 1, 1],
|
||||
},
|
||||
});
|
||||
} else if (penMode.value === 'blur') {
|
||||
layers.push({
|
||||
id,
|
||||
fxId: 'blur',
|
||||
params: {
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
scaleX: 0.1,
|
||||
scaleY: 0.1,
|
||||
angle: 0,
|
||||
radius: 3,
|
||||
},
|
||||
});
|
||||
} else if (penMode.value === 'pixelate') {
|
||||
layers.push({
|
||||
id,
|
||||
fxId: 'pixelate',
|
||||
params: {
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
scaleX: 0.1,
|
||||
scaleY: 0.1,
|
||||
angle: 0,
|
||||
strength: 0.2,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
_move(ev.offsetX, ev.offsetY);
|
||||
|
||||
function _move(pointerX: number, pointerY: number) {
|
||||
let x = pointerX - xOffset;
|
||||
let y = pointerY - yOffset;
|
||||
|
||||
if (AW / AH < BW / BH) { // 横長
|
||||
x = x / (Math.max(AW, AH) / Math.max(BH / BW, 1));
|
||||
y = y / (Math.max(AW, AH) / Math.max(BW / BH, 1));
|
||||
} else { // 縦長
|
||||
x = x / (Math.min(AW, AH) / Math.max(BH / BW, 1));
|
||||
y = y / (Math.min(AW, AH) / Math.max(BW / BH, 1));
|
||||
}
|
||||
|
||||
const scaleX = Math.abs(x - startX);
|
||||
const scaleY = Math.abs(y - startY);
|
||||
|
||||
const layerIndex = layers.findIndex((l) => l.id === id);
|
||||
const layer = layerIndex !== -1 ? layers[layerIndex] : null;
|
||||
if (layer != null) {
|
||||
layer.params.offsetX = (x + startX) - 1;
|
||||
layer.params.offsetY = (y + startY) - 1;
|
||||
layer.params.scaleX = scaleX;
|
||||
layer.params.scaleY = scaleY;
|
||||
layers[layerIndex] = layer;
|
||||
}
|
||||
}
|
||||
|
||||
function move(ev: PointerEvent) {
|
||||
_move(ev.offsetX, ev.offsetY);
|
||||
}
|
||||
|
||||
function up() {
|
||||
canvasEl.value?.removeEventListener('pointermove', move);
|
||||
canvasEl.value?.removeEventListener('pointerup', up);
|
||||
canvasEl.value?.removeEventListener('pointercancel', up);
|
||||
canvasEl.value?.releasePointerCapture(ev.pointerId);
|
||||
|
||||
penMode.value = null;
|
||||
}
|
||||
|
||||
canvasEl.value.addEventListener('pointermove', move);
|
||||
canvasEl.value.addEventListener('pointerup', up);
|
||||
canvasEl.value.setPointerCapture(ev.pointerId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
|
@ -251,6 +395,18 @@ watch(enabled, () => {
|
|||
font-size: 85%;
|
||||
}
|
||||
|
||||
.editControls {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.previewControls {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
|
@ -283,9 +439,13 @@ watch(enabled, () => {
|
|||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
/* なんかiOSでレンダリングがおかしい
|
||||
width: stretch;
|
||||
height: stretch;
|
||||
*/
|
||||
width: calc(100% - 40px);
|
||||
height: calc(100% - 40px);
|
||||
margin: 20px;
|
||||
box-sizing: border-box;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
|
|
@ -15,101 +15,151 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
@esc="cancel()"
|
||||
>
|
||||
<template #header>
|
||||
{{ i18n.ts.drafts }} ({{ currentDraftsCount }}/{{ $i?.policies.noteDraftLimit }})
|
||||
{{ i18n.ts.draftsAndScheduledNotes }} ({{ currentDraftsCount }}/{{ $i?.policies.noteDraftLimit }})
|
||||
</template>
|
||||
<div class="_spacer">
|
||||
<MkPagination :paginator="paginator" withControl>
|
||||
<template #empty>
|
||||
<MkResult type="empty" :text="i18n.ts._drafts.noDrafts"/>
|
||||
</template>
|
||||
|
||||
<template #default="{ items }">
|
||||
<div class="_gaps_s">
|
||||
<div
|
||||
v-for="draft in (items as unknown as Misskey.entities.NoteDraft[])"
|
||||
:key="draft.id"
|
||||
v-panel
|
||||
:class="[$style.draft]"
|
||||
>
|
||||
<div :class="$style.draftBody" class="_gaps_s">
|
||||
<div :class="$style.draftInfo">
|
||||
<div :class="$style.draftMeta">
|
||||
<div v-if="draft.reply" class="_nowrap">
|
||||
<i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span">
|
||||
<template #user>
|
||||
<Mfm v-if="draft.reply.user.name != null" :text="draft.reply.user.name" :plain="true" :nowrap="true"/>
|
||||
<MkAcct v-else :user="draft.reply.user"/>
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div v-else-if="draft.replyId" class="_nowrap">
|
||||
<i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span">
|
||||
<template #user>
|
||||
{{ i18n.ts.deletedNote }}
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div v-if="draft.renote && draft.text != null" class="_nowrap">
|
||||
<i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span">
|
||||
<template #user>
|
||||
<Mfm v-if="draft.renote.user.name != null" :text="draft.renote.user.name" :plain="true" :nowrap="true"/>
|
||||
<MkAcct v-else :user="draft.renote.user"/>
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div v-else-if="draft.renoteId" class="_nowrap">
|
||||
<i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span">
|
||||
<template #user>
|
||||
{{ i18n.ts.deletedNote }}
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div v-if="draft.channel" class="_nowrap">
|
||||
<i class="ti ti-device-tv"></i> {{ i18n.tsx._drafts.postTo({ channel: draft.channel.name }) }}
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<MkTabs
|
||||
v-model:tab="tab"
|
||||
centered
|
||||
:class="$style.tabs"
|
||||
:tabs="[
|
||||
{
|
||||
key: 'drafts',
|
||||
title: i18n.ts.drafts,
|
||||
icon: 'ti ti-pencil-question',
|
||||
},
|
||||
{
|
||||
key: 'scheduled',
|
||||
title: i18n.ts.scheduled,
|
||||
icon: 'ti ti-calendar-clock',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="_spacer">
|
||||
<MkPagination :key="tab" :paginator="tab === 'scheduled' ? scheduledPaginator : draftsPaginator" withControl>
|
||||
<template #empty>
|
||||
<MkResult type="empty" :text="i18n.ts._drafts.noDrafts"/>
|
||||
</template>
|
||||
|
||||
<template #default="{ items }">
|
||||
<div class="_gaps_s">
|
||||
<div
|
||||
v-for="draft in (items as unknown as Misskey.entities.NoteDraft[])"
|
||||
:key="draft.id"
|
||||
v-panel
|
||||
:class="[$style.draft]"
|
||||
>
|
||||
<div :class="$style.draftBody" class="_gaps_s">
|
||||
<MkInfo v-if="draft.scheduledAt != null && draft.isActuallyScheduled">
|
||||
<I18n :src="i18n.ts.scheduledToPostOnX" tag="span">
|
||||
<template #x>
|
||||
<MkTime :time="draft.scheduledAt" :mode="'detail'" style="font-weight: bold;"/>
|
||||
</template>
|
||||
</I18n>
|
||||
</MkInfo>
|
||||
<div :class="$style.draftInfo">
|
||||
<div :class="$style.draftMeta">
|
||||
<div v-if="draft.reply" class="_nowrap">
|
||||
<i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span">
|
||||
<template #user>
|
||||
<Mfm v-if="draft.reply.user.name != null" :text="draft.reply.user.name" :plain="true" :nowrap="true"/>
|
||||
<MkAcct v-else :user="draft.reply.user"/>
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div v-else-if="draft.replyId" class="_nowrap">
|
||||
<i class="ti ti-arrow-back-up"></i> <I18n :src="i18n.ts._drafts.replyTo" tag="span">
|
||||
<template #user>
|
||||
{{ i18n.ts.deletedNote }}
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div v-if="draft.renote && draft.text != null" class="_nowrap">
|
||||
<i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span">
|
||||
<template #user>
|
||||
<Mfm v-if="draft.renote.user.name != null" :text="draft.renote.user.name" :plain="true" :nowrap="true"/>
|
||||
<MkAcct v-else :user="draft.renote.user"/>
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div v-else-if="draft.renoteId" class="_nowrap">
|
||||
<i class="ti ti-quote"></i> <I18n :src="i18n.ts._drafts.quoteOf" tag="span">
|
||||
<template #user>
|
||||
{{ i18n.ts.deletedNote }}
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div v-if="draft.channel" class="_nowrap">
|
||||
<i class="ti ti-device-tv"></i> {{ i18n.tsx._drafts.postTo({ channel: draft.channel.name }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.draftContent">
|
||||
<Mfm :text="getNoteSummary(draft, { showRenote: false, showReply: false })" :plain="true" :author="draft.user"/>
|
||||
</div>
|
||||
<div :class="$style.draftFooter">
|
||||
<div :class="$style.draftVisibility">
|
||||
<span :title="i18n.ts._visibility[draft.visibility]">
|
||||
<i v-if="draft.visibility === 'public'" class="ti ti-world"></i>
|
||||
<i v-else-if="draft.visibility === 'home'" class="ti ti-home"></i>
|
||||
<i v-else-if="draft.visibility === 'followers'" class="ti ti-lock"></i>
|
||||
<i v-else-if="draft.visibility === 'specified'" class="ti ti-mail"></i>
|
||||
</span>
|
||||
<span v-if="draft.localOnly" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
|
||||
<div :class="$style.draftContent">
|
||||
<Mfm :text="getNoteSummary(draft, { showRenote: false, showReply: false })" :plain="true" :author="draft.user"/>
|
||||
</div>
|
||||
<div :class="$style.draftFooter">
|
||||
<div :class="$style.draftVisibility">
|
||||
<span :title="i18n.ts._visibility[draft.visibility]">
|
||||
<i v-if="draft.visibility === 'public'" class="ti ti-world"></i>
|
||||
<i v-else-if="draft.visibility === 'home'" class="ti ti-home"></i>
|
||||
<i v-else-if="draft.visibility === 'followers'" class="ti ti-lock"></i>
|
||||
<i v-else-if="draft.visibility === 'specified'" class="ti ti-mail"></i>
|
||||
</span>
|
||||
<span v-if="draft.localOnly" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
|
||||
</div>
|
||||
<MkTime :time="draft.createdAt" :class="$style.draftCreatedAt" mode="detail" colored/>
|
||||
</div>
|
||||
<MkTime :time="draft.createdAt" :class="$style.draftCreatedAt" mode="detail" colored/>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.draftActions" class="_buttons">
|
||||
<MkButton
|
||||
:class="$style.itemButton"
|
||||
small
|
||||
@click="restoreDraft(draft)"
|
||||
>
|
||||
<i class="ti ti-corner-up-left"></i>
|
||||
{{ i18n.ts._drafts.restore }}
|
||||
</MkButton>
|
||||
<MkButton
|
||||
v-tooltip="i18n.ts._drafts.delete"
|
||||
danger
|
||||
small
|
||||
:iconOnly="true"
|
||||
:class="$style.itemButton"
|
||||
@click="deleteDraft(draft)"
|
||||
>
|
||||
<i class="ti ti-trash"></i>
|
||||
</MkButton>
|
||||
|
||||
<div :class="$style.draftActions" class="_buttons">
|
||||
<template v-if="draft.scheduledAt != null && draft.isActuallyScheduled">
|
||||
<MkButton
|
||||
:class="$style.itemButton"
|
||||
small
|
||||
@click="cancelSchedule(draft)"
|
||||
>
|
||||
<i class="ti ti-calendar-x"></i> {{ i18n.ts._drafts.cancelSchedule }}
|
||||
</MkButton>
|
||||
<!-- TODO
|
||||
<MkButton
|
||||
:class="$style.itemButton"
|
||||
small
|
||||
@click="reSchedule(draft)"
|
||||
>
|
||||
<i class="ti ti-calendar-time"></i> {{ i18n.ts._drafts.reSchedule }}
|
||||
</MkButton>
|
||||
-->
|
||||
</template>
|
||||
<MkButton
|
||||
v-else
|
||||
:class="$style.itemButton"
|
||||
small
|
||||
@click="restoreDraft(draft)"
|
||||
>
|
||||
<i class="ti ti-corner-up-left"></i> {{ i18n.ts._drafts.restore }}
|
||||
</MkButton>
|
||||
<MkButton
|
||||
v-tooltip="i18n.ts._drafts.delete"
|
||||
danger
|
||||
small
|
||||
:iconOnly="true"
|
||||
:class="$style.itemButton"
|
||||
style="margin-left: auto;"
|
||||
@click="deleteDraft(draft)"
|
||||
>
|
||||
<i class="ti ti-trash"></i>
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</MkStickyContainer>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
|
@ -125,6 +175,12 @@ import * as os from '@/os.js';
|
|||
import { $i } from '@/i.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api';
|
||||
import { Paginator } from '@/utility/paginator.js';
|
||||
import MkTabs from '@/components/MkTabs.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
scheduled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'restore', draft: Misskey.entities.NoteDraft): void;
|
||||
|
@ -132,8 +188,20 @@ const emit = defineEmits<{
|
|||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const paginator = markRaw(new Paginator('notes/drafts/list', {
|
||||
const tab = ref<'drafts' | 'scheduled'>(props.scheduled ? 'scheduled' : 'drafts');
|
||||
|
||||
const draftsPaginator = markRaw(new Paginator('notes/drafts/list', {
|
||||
limit: 10,
|
||||
params: {
|
||||
scheduled: false,
|
||||
},
|
||||
}));
|
||||
|
||||
const scheduledPaginator = markRaw(new Paginator('notes/drafts/list', {
|
||||
limit: 10,
|
||||
params: {
|
||||
scheduled: true,
|
||||
},
|
||||
}));
|
||||
|
||||
const currentDraftsCount = ref(0);
|
||||
|
@ -162,7 +230,17 @@ async function deleteDraft(draft: Misskey.entities.NoteDraft) {
|
|||
if (canceled) return;
|
||||
|
||||
os.apiWithDialog('notes/drafts/delete', { draftId: draft.id }).then(() => {
|
||||
paginator.reload();
|
||||
draftsPaginator.reload();
|
||||
});
|
||||
}
|
||||
|
||||
async function cancelSchedule(draft: Misskey.entities.NoteDraft) {
|
||||
os.apiWithDialog('notes/drafts/update', {
|
||||
draftId: draft.id,
|
||||
isActuallyScheduled: false,
|
||||
scheduledAt: null,
|
||||
}).then(() => {
|
||||
scheduledPaginator.reload();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
@ -220,4 +298,11 @@ async function deleteDraft(draft: Misskey.entities.NoteDraft) {
|
|||
padding-top: 16px;
|
||||
border-top: solid 1px var(--MI_THEME-divider);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
background: color(from var(--MI_THEME-bg) srgb r g b / 0.75);
|
||||
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
|
||||
backdrop-filter: var(--MI-blur, blur(15px));
|
||||
border-bottom: solid 0.5px var(--MI_THEME-divider);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div :class="$style.root">
|
||||
<div :class="$style.head">
|
||||
<MkAvatar v-if="['pollEnded', 'note'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/>
|
||||
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'createToken'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
|
||||
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login', 'createToken', 'scheduledNotePosted', 'scheduledNotePostFailed'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
|
||||
<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div>
|
||||
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
|
||||
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
|
||||
|
@ -23,6 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
[$style.t_mention]: notification.type === 'mention',
|
||||
[$style.t_quote]: notification.type === 'quote',
|
||||
[$style.t_pollEnded]: notification.type === 'pollEnded',
|
||||
[$style.t_scheduledNotePosted]: notification.type === 'scheduledNotePosted',
|
||||
[$style.t_scheduledNotePostFailed]: notification.type === 'scheduledNotePostFailed',
|
||||
[$style.t_achievementEarned]: notification.type === 'achievementEarned',
|
||||
[$style.t_exportCompleted]: notification.type === 'exportCompleted',
|
||||
[$style.t_login]: notification.type === 'login',
|
||||
|
@ -39,6 +41,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i v-else-if="notification.type === 'mention'" class="ti ti-at"></i>
|
||||
<i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i>
|
||||
<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
|
||||
<i v-else-if="notification.type === 'scheduledNotePosted'" class="ti ti-send"></i>
|
||||
<i v-else-if="notification.type === 'scheduledNotePostFailed'" class="ti ti-alert-triangle"></i>
|
||||
<i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i>
|
||||
<i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i>
|
||||
<i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i>
|
||||
|
@ -60,6 +64,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div :class="$style.tail">
|
||||
<header :class="$style.header">
|
||||
<span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span>
|
||||
<span v-else-if="notification.type === 'scheduledNotePosted'">{{ i18n.ts._notification.scheduledNotePosted }}</span>
|
||||
<span v-else-if="notification.type === 'scheduledNotePostFailed'">{{ i18n.ts._notification.scheduledNotePostFailed }}</span>
|
||||
<span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span>
|
||||
<span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span>
|
||||
<span v-else-if="notification.type === 'chatRoomInvitationReceived'">{{ i18n.ts._notification.chatRoomInvitationReceived }}</span>
|
||||
|
@ -103,6 +109,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
|
||||
<i class="ti ti-quote" :class="$style.quote"></i>
|
||||
</MkA>
|
||||
<MkA v-else-if="notification.type === 'scheduledNotePosted'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
|
||||
<i class="ti ti-quote" :class="$style.quote"></i>
|
||||
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/>
|
||||
<i class="ti ti-quote" :class="$style.quote"></i>
|
||||
</MkA>
|
||||
<div v-else-if="notification.type === 'roleAssigned'" :class="$style.text">
|
||||
{{ notification.role.name }}
|
||||
</div>
|
||||
|
@ -338,6 +349,16 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.t_scheduledNotePosted {
|
||||
background: var(--eventOther);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.t_scheduledNotePostFailed {
|
||||
background: var(--eventOther);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.t_achievementEarned {
|
||||
background: var(--eventAchievement);
|
||||
pointer-events: none;
|
||||
|
|
|
@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<p v-if="page.summary" :title="page.summary">{{ page.summary.length > 85 ? page.summary.slice(0, 85) + '…' : page.summary }}</p>
|
||||
<footer>
|
||||
<img v-if="page.user.avatarUrl" class="icon" :src="page.user.avatarUrl"/>
|
||||
<p>{{ userName(page.user) }}</p>
|
||||
<MkUserName class="name" :user="page.user"/>
|
||||
</footer>
|
||||
</article>
|
||||
</MkA>
|
||||
|
@ -30,7 +30,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { userName } from '@/filters/user.js';
|
||||
import MediaImage from '@/components/MkMediaImage.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
|
@ -112,7 +111,7 @@ const props = defineProps<{
|
|||
vertical-align: top;
|
||||
}
|
||||
|
||||
> p {
|
||||
> .name {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
font-size: 0.8em;
|
||||
|
|
|
@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #header>
|
||||
<template v-if="pageMetadata">
|
||||
<i v-if="pageMetadata.icon" :class="pageMetadata.icon" style="margin-right: 0.5em;"></i>
|
||||
<span>{{ pageMetadata.title }}</span>
|
||||
<Mfm :text="pageMetadata.title" :plain="true"/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -4,14 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div :class="[$style.root, accented ? $style.accented : null]"></div>
|
||||
<div :class="[$style.root, accented ? $style.accented : null, revered ? $style.revered : null]"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const props = withDefaults(defineProps<{
|
||||
accented?: boolean;
|
||||
revered?: boolean;
|
||||
height?: number;
|
||||
}>(), {
|
||||
accented: false,
|
||||
revered: false,
|
||||
height: 200,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@ -27,14 +31,17 @@ const props = withDefaults(defineProps<{
|
|||
--dot-size: 2px;
|
||||
--gap-size: 40px;
|
||||
--offset: calc(var(--gap-size) / 2);
|
||||
--height: v-bind('props.height + "px"');
|
||||
|
||||
height: 200px;
|
||||
margin-bottom: -200px;
|
||||
|
||||
height: var(--height);
|
||||
background-image: linear-gradient(transparent 60%, transparent 100%), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size));
|
||||
background-position: 0 0, 0 0, var(--offset) var(--offset);
|
||||
background-size: 100% 100%, var(--gap-size) var(--gap-size), var(--gap-size) var(--gap-size);
|
||||
mask-image: linear-gradient(to bottom, black 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
|
||||
&.revered {
|
||||
mask-image: linear-gradient(to top, black 0%, transparent 100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -6,15 +6,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<div :class="[$style.root]">
|
||||
<div :class="$style.items">
|
||||
<button class="_button" :class="[$style.item, x === 'left' && y === 'top' ? $style.active : null]" @click="() => { x = 'left'; y = 'top'; }"><i class="ti ti-align-box-left-top"></i></button>
|
||||
<button class="_button" :class="[$style.item, x === 'center' && y === 'top' ? $style.active : null]" @click="() => { x = 'center'; y = 'top'; }"><i class="ti ti-align-box-center-top"></i></button>
|
||||
<button class="_button" :class="[$style.item, x === 'right' && y === 'top' ? $style.active : null]" @click="() => { x = 'right'; y = 'top'; }"><i class="ti ti-align-box-right-top"></i></button>
|
||||
<button class="_button" :class="[$style.item, x === 'left' && y === 'center' ? $style.active : null]" @click="() => { x = 'left'; y = 'center'; }"><i class="ti ti-align-box-left-middle"></i></button>
|
||||
<button class="_button" :class="[$style.item, x === 'center' && y === 'center' ? $style.active : null]" @click="() => { x = 'center'; y = 'center'; }"><i class="ti ti-align-box-center-middle"></i></button>
|
||||
<button class="_button" :class="[$style.item, x === 'right' && y === 'center' ? $style.active : null]" @click="() => { x = 'right'; y = 'center'; }"><i class="ti ti-align-box-right-middle"></i></button>
|
||||
<button class="_button" :class="[$style.item, x === 'left' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'left'; y = 'bottom'; }"><i class="ti ti-align-box-left-bottom"></i></button>
|
||||
<button class="_button" :class="[$style.item, x === 'center' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'center'; y = 'bottom'; }"><i class="ti ti-align-box-center-bottom"></i></button>
|
||||
<button class="_button" :class="[$style.item, x === 'right' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'right'; y = 'bottom'; }"><i class="ti ti-align-box-right-bottom"></i></button>
|
||||
<button v-panel class="_button" :class="[$style.item, x === 'left' && y === 'top' ? $style.active : null]" @click="() => { x = 'left'; y = 'top'; }"><i class="ti ti-arrow-up-left"></i></button>
|
||||
<button v-panel class="_button" :class="[$style.item, x === 'center' && y === 'top' ? $style.active : null]" @click="() => { x = 'center'; y = 'top'; }"><i class="ti ti-arrow-up"></i></button>
|
||||
<button v-panel class="_button" :class="[$style.item, x === 'right' && y === 'top' ? $style.active : null]" @click="() => { x = 'right'; y = 'top'; }"><i class="ti ti-arrow-up-right"></i></button>
|
||||
<button v-panel class="_button" :class="[$style.item, x === 'left' && y === 'center' ? $style.active : null]" @click="() => { x = 'left'; y = 'center'; }"><i class="ti ti-arrow-left"></i></button>
|
||||
<button v-panel class="_button" :class="[$style.item, x === 'center' && y === 'center' ? $style.active : null]" @click="() => { x = 'center'; y = 'center'; }"><i class="ti ti-focus-2"></i></button>
|
||||
<button v-panel class="_button" :class="[$style.item, x === 'right' && y === 'center' ? $style.active : null]" @click="() => { x = 'right'; y = 'center'; }"><i class="ti ti-arrow-right"></i></button>
|
||||
<button v-panel class="_button" :class="[$style.item, x === 'left' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'left'; y = 'bottom'; }"><i class="ti ti-arrow-down-left"></i></button>
|
||||
<button v-panel class="_button" :class="[$style.item, x === 'center' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'center'; y = 'bottom'; }"><i class="ti ti-arrow-down"></i></button>
|
||||
<button v-panel class="_button" :class="[$style.item, x === 'right' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'right'; y = 'bottom'; }"><i class="ti ti-arrow-down-right"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -15,9 +15,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div :class="$style.headerLeft">
|
||||
<button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button>
|
||||
<button v-click-anime v-tooltip="i18n.ts.switchAccount" :class="$style.account" class="_button" @click="openAccountMenu">
|
||||
<MkAvatar :user="postAccount ?? $i" :class="$style.avatar"/>
|
||||
<img :class="$style.avatar" :src="(postAccount ?? $i).avatarUrl" style="border-radius: 100%;"/>
|
||||
</button>
|
||||
<button v-if="$i.policies.noteDraftLimit > 0" v-tooltip="(postAccount != null && postAccount.id !== $i.id) ? null : i18n.ts.draft" class="_button" :class="$style.draftButton" :disabled="postAccount != null && postAccount.id !== $i.id" @click="showDraftMenu"><i class="ti ti-pencil-minus"></i></button>
|
||||
<button v-if="$i.policies.noteDraftLimit > 0" v-tooltip="(postAccount != null && postAccount.id !== $i.id) ? null : i18n.ts.draftsAndScheduledNotes" class="_button" :class="$style.draftButton" :disabled="postAccount != null && postAccount.id !== $i.id" @click="showDraftMenu"><i class="ti ti-list"></i></button>
|
||||
</div>
|
||||
<div :class="$style.headerRight">
|
||||
<template v-if="!(targetChannel != null && fixed)">
|
||||
|
@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template v-if="posted"></template>
|
||||
<template v-else-if="posting"><MkEllipsis/></template>
|
||||
<template v-else>{{ submitText }}</template>
|
||||
<i style="margin-left: 6px;" :class="posted ? 'ti ti-check' : replyTargetNote ? 'ti ti-arrow-back-up' : renoteTargetNote ? 'ti ti-quote' : 'ti ti-send'"></i>
|
||||
<i style="margin-left: 6px;" :class="submitIcon"></i>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -61,6 +61,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<button class="_buttonPrimary" style="padding: 4px; border-radius: 8px;" @click="addVisibleUser"><i class="ti ti-plus ti-fw"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<MkInfo v-if="scheduledAt != null" :class="$style.scheduledAt">
|
||||
<I18n :src="i18n.ts.scheduleToPostOnX" tag="span">
|
||||
<template #x>
|
||||
<MkTime :time="scheduledAt" :mode="'detail'" style="font-weight: bold;"/>
|
||||
</template>
|
||||
</I18n> - <button class="_textButton" @click="cancelSchedule()">{{ i18n.ts.cancel }}</button>
|
||||
</MkInfo>
|
||||
<MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
|
||||
<div v-show="useCw" :class="$style.cwOuter">
|
||||
<input ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown" @keyup="onKeyup" @compositionend="onCompositionEnd">
|
||||
|
@ -105,7 +112,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef } from 'vue';
|
||||
import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef, onUnmounted } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import insertTextAtCursor from 'insert-text-at-cursor';
|
||||
|
@ -199,6 +206,7 @@ if (props.initialVisibleUsers) {
|
|||
props.initialVisibleUsers.forEach(u => pushVisibleUser(u));
|
||||
}
|
||||
const reactionAcceptance = ref(store.s.reactionAcceptance);
|
||||
const scheduledAt = ref<number | null>(null);
|
||||
const draghover = ref(false);
|
||||
const quoteId = ref<string | null>(null);
|
||||
const hasNotSpecifiedMentions = ref(false);
|
||||
|
@ -218,6 +226,10 @@ const uploader = useUploader({
|
|||
multiple: true,
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
uploader.dispose();
|
||||
});
|
||||
|
||||
uploader.events.on('itemUploaded', ctx => {
|
||||
files.value.push(ctx.item.uploaded!);
|
||||
uploader.removeItem(ctx.item);
|
||||
|
@ -258,11 +270,17 @@ const placeholder = computed((): string => {
|
|||
});
|
||||
|
||||
const submitText = computed((): string => {
|
||||
return renoteTargetNote.value
|
||||
? i18n.ts.quote
|
||||
: replyTargetNote.value
|
||||
? i18n.ts.reply
|
||||
: i18n.ts.note;
|
||||
return scheduledAt.value != null
|
||||
? i18n.ts.schedule
|
||||
: renoteTargetNote.value
|
||||
? i18n.ts.quote
|
||||
: replyTargetNote.value
|
||||
? i18n.ts.reply
|
||||
: i18n.ts.note;
|
||||
});
|
||||
|
||||
const submitIcon = computed((): string => {
|
||||
return posted.value ? 'ti ti-check' : scheduledAt.value != null ? 'ti ti-calendar-time' : replyTargetNote.value ? 'ti ti-arrow-back-up' : renoteTargetNote.value ? 'ti ti-quote' : 'ti ti-send';
|
||||
});
|
||||
|
||||
const textLength = computed((): number => {
|
||||
|
@ -410,6 +428,7 @@ function watchForDraft() {
|
|||
watch(localOnly, () => saveDraft());
|
||||
watch(quoteId, () => saveDraft());
|
||||
watch(reactionAcceptance, () => saveDraft());
|
||||
watch(scheduledAt, () => saveDraft());
|
||||
}
|
||||
|
||||
function checkMissingMention() {
|
||||
|
@ -601,7 +620,13 @@ function showOtherSettings() {
|
|||
action: () => {
|
||||
toggleReactionAcceptance();
|
||||
},
|
||||
}, { type: 'divider' }, {
|
||||
}, ...($i.policies.scheduledNoteLimit > 0 ? [{
|
||||
icon: 'ti ti-calendar-time',
|
||||
text: i18n.ts.schedulePost + '...',
|
||||
action: () => {
|
||||
schedule();
|
||||
},
|
||||
}] : []), { type: 'divider' }, {
|
||||
type: 'switch',
|
||||
icon: 'ti ti-eye',
|
||||
text: i18n.ts.preview,
|
||||
|
@ -650,6 +675,7 @@ function clear() {
|
|||
files.value = [];
|
||||
poll.value = null;
|
||||
quoteId.value = null;
|
||||
scheduledAt.value = null;
|
||||
}
|
||||
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
|
@ -805,6 +831,7 @@ function saveDraft() {
|
|||
...( visibleUsers.value.length > 0 ? { visibleUserIds: visibleUsers.value.map(x => x.id) } : {}),
|
||||
quoteId: quoteId.value,
|
||||
reactionAcceptance: reactionAcceptance.value,
|
||||
scheduledAt: scheduledAt.value,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -819,7 +846,9 @@ function deleteDraft() {
|
|||
miLocalStorage.setItem('drafts', JSON.stringify(draftData));
|
||||
}
|
||||
|
||||
async function saveServerDraft(clearLocal = false) {
|
||||
async function saveServerDraft(options: {
|
||||
isActuallyScheduled?: boolean;
|
||||
} = {}) {
|
||||
return await os.apiWithDialog(serverDraftId.value == null ? 'notes/drafts/create' : 'notes/drafts/update', {
|
||||
...(serverDraftId.value == null ? {} : { draftId: serverDraftId.value }),
|
||||
text: text.value,
|
||||
|
@ -827,19 +856,15 @@ async function saveServerDraft(clearLocal = false) {
|
|||
visibility: visibility.value,
|
||||
localOnly: localOnly.value,
|
||||
hashtag: hashtags.value,
|
||||
...(files.value.length > 0 ? { fileIds: files.value.map(f => f.id) } : {}),
|
||||
fileIds: files.value.map(f => f.id),
|
||||
poll: poll.value,
|
||||
...(visibleUsers.value.length > 0 ? { visibleUserIds: visibleUsers.value.map(x => x.id) } : {}),
|
||||
renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : undefined,
|
||||
replyId: replyTargetNote.value ? replyTargetNote.value.id : undefined,
|
||||
channelId: targetChannel.value ? targetChannel.value.id : undefined,
|
||||
visibleUserIds: visibleUsers.value.map(x => x.id),
|
||||
renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : null,
|
||||
replyId: replyTargetNote.value ? replyTargetNote.value.id : null,
|
||||
channelId: targetChannel.value ? targetChannel.value.id : null,
|
||||
reactionAcceptance: reactionAcceptance.value,
|
||||
}).then(() => {
|
||||
if (clearLocal) {
|
||||
clear();
|
||||
deleteDraft();
|
||||
}
|
||||
}).catch((err) => {
|
||||
scheduledAt: scheduledAt.value,
|
||||
isActuallyScheduled: options.isActuallyScheduled ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -874,6 +899,21 @@ async function post(ev?: MouseEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
if (scheduledAt.value != null) {
|
||||
if (uploader.items.value.some(x => x.uploaded == null)) {
|
||||
await uploadFiles();
|
||||
|
||||
// アップロード失敗したものがあったら中止
|
||||
if (uploader.items.value.some(x => x.uploaded == null)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await postAsScheduled();
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.mock) return;
|
||||
|
||||
if (visibility.value === 'public' && (
|
||||
|
@ -1045,6 +1085,14 @@ async function post(ev?: MouseEvent) {
|
|||
});
|
||||
}
|
||||
|
||||
async function postAsScheduled() {
|
||||
if (props.mock) return;
|
||||
|
||||
await saveServerDraft({
|
||||
isActuallyScheduled: true,
|
||||
});
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
emit('cancel');
|
||||
}
|
||||
|
@ -1139,8 +1187,10 @@ function showPerUploadItemMenuViaContextmenu(item: UploaderItem, ev: MouseEvent)
|
|||
}
|
||||
|
||||
function showDraftMenu(ev: MouseEvent) {
|
||||
function showDraftsDialog() {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNoteDraftsDialog.vue')), {}, {
|
||||
function showDraftsDialog(scheduled: boolean) {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkNoteDraftsDialog.vue')), {
|
||||
scheduled,
|
||||
}, {
|
||||
restore: async (draft: Misskey.entities.NoteDraft) => {
|
||||
text.value = draft.text ?? '';
|
||||
useCw.value = draft.cw != null;
|
||||
|
@ -1171,6 +1221,7 @@ function showDraftMenu(ev: MouseEvent) {
|
|||
renoteTargetNote.value = draft.renote;
|
||||
replyTargetNote.value = draft.reply;
|
||||
reactionAcceptance.value = draft.reactionAcceptance;
|
||||
scheduledAt.value = draft.scheduledAt ?? null;
|
||||
if (draft.channel) targetChannel.value = draft.channel as unknown as Misskey.entities.Channel;
|
||||
|
||||
visibleUsers.value = [];
|
||||
|
@ -1211,11 +1262,32 @@ function showDraftMenu(ev: MouseEvent) {
|
|||
text: i18n.ts._drafts.listDrafts,
|
||||
icon: 'ti ti-cloud-download',
|
||||
action: () => {
|
||||
showDraftsDialog();
|
||||
showDraftsDialog(false);
|
||||
},
|
||||
}, { type: 'divider' }, {
|
||||
type: 'button',
|
||||
text: i18n.ts._drafts.listScheduledNotes,
|
||||
icon: 'ti ti-clock-down',
|
||||
action: () => {
|
||||
showDraftsDialog(true);
|
||||
},
|
||||
}], (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
}
|
||||
|
||||
async function schedule() {
|
||||
const { canceled, result } = await os.inputDatetime({
|
||||
title: i18n.ts.schedulePost,
|
||||
});
|
||||
if (canceled) return;
|
||||
if (result.getTime() <= Date.now()) return;
|
||||
|
||||
scheduledAt.value = result.getTime();
|
||||
}
|
||||
|
||||
function cancelSchedule() {
|
||||
scheduledAt.value = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autofocus) {
|
||||
focus();
|
||||
|
@ -1251,6 +1323,7 @@ onMounted(() => {
|
|||
}
|
||||
quoteId.value = draft.data.quoteId;
|
||||
reactionAcceptance.value = draft.data.reactionAcceptance;
|
||||
scheduledAt.value = draft.data.scheduledAt ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1300,6 +1373,7 @@ async function canClose() {
|
|||
|
||||
defineExpose({
|
||||
clear,
|
||||
abortUploader: () => uploader.abortAll(),
|
||||
canClose,
|
||||
});
|
||||
</script>
|
||||
|
@ -1514,6 +1588,10 @@ html[data-color-scheme=light] .preview {
|
|||
margin: 0 20px 16px 20px;
|
||||
}
|
||||
|
||||
.scheduledAt {
|
||||
margin: 0 20px 16px 20px;
|
||||
}
|
||||
|
||||
.cw,
|
||||
.hashtags,
|
||||
.text {
|
||||
|
|
|
@ -54,6 +54,7 @@ function onPosted() {
|
|||
async function _close() {
|
||||
const canClose = await form.value?.canClose();
|
||||
if (!canClose) return;
|
||||
form.value?.abortUploader();
|
||||
modal.value?.close();
|
||||
}
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
>
|
||||
<div v-if="showing" class="_acrylic" :class="$style.root" :style="{ zIndex }">
|
||||
<div style="padding: 16px 24px;">
|
||||
{{ message }}
|
||||
<Mfm :text="message" :plain="true" :nowrap="true"/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
|
|
@ -10,7 +10,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:key="item.id"
|
||||
v-panel
|
||||
:class="[$style.item, { [$style.itemWaiting]: item.preprocessing, [$style.itemCompleted]: item.uploaded, [$style.itemFailed]: item.uploadFailed }]"
|
||||
:style="{ '--p': item.progress != null ? `${item.progress.value / item.progress.max * 100}%` : '0%' }"
|
||||
:style="{
|
||||
'--p': item.progress != null ? `${item.progress.value / item.progress.max * 100}%` : '0%',
|
||||
'--pp': item.preprocessProgress != null ? `${item.preprocessProgress * 100}%` : '100%',
|
||||
}"
|
||||
@contextmenu.prevent.stop="onContextmenu(item, $event)"
|
||||
>
|
||||
<div :class="$style.itemInner">
|
||||
|
@ -19,11 +22,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ item.thumbnail })` }" @click="onThumbnailClick(item, $event)"></div>
|
||||
<div :class="$style.itemBody">
|
||||
<div><i v-if="item.isSensitive" style="color: var(--MI_THEME-warn); margin-right: 0.5em;" class="ti ti-eye-exclamation"></i><MkCondensedLine :minScale="2 / 3">{{ item.name }}</MkCondensedLine></div>
|
||||
<div>
|
||||
<i v-if="item.isSensitive" style="color: var(--MI_THEME-warn); margin-right: 0.5em;" class="ti ti-eye-exclamation"></i>
|
||||
<MkCondensedLine :minScale="2 / 3">{{ item.name }}</MkCondensedLine>
|
||||
</div>
|
||||
<div :class="$style.itemInfo">
|
||||
<span>{{ item.file.type }}</span>
|
||||
<span v-if="item.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(item.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - item.compressedSize / item.file.size) * 100) }) }})</span>
|
||||
<span v-else>{{ bytes(item.file.size) }}</span>
|
||||
<span v-if="item.preprocessing">{{ i18n.ts.preprocessing }}<MkLoading inline em style="margin-left: 0.5em;"/></span>
|
||||
</div>
|
||||
<div>
|
||||
</div>
|
||||
|
@ -97,7 +104,7 @@ function onThumbnailClick(item: UploaderItem, ev: MouseEvent) {
|
|||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
width: var(--pp, 100%);
|
||||
height: 100%;
|
||||
background: linear-gradient(-45deg, transparent 25%, var(--c) 25%,var(--c) 50%, transparent 50%, transparent 75%, var(--c) 75%, var(--c));
|
||||
background-size: 25px 25px;
|
||||
|
|
|
@ -18,6 +18,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
></MkPositionSelector>
|
||||
</FormSlot>
|
||||
|
||||
<MkRange
|
||||
:modelValue="layer.align.margin ?? 0"
|
||||
:min="0"
|
||||
:max="0.25"
|
||||
:step="0.01"
|
||||
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
|
||||
continuousUpdate
|
||||
@update:modelValue="(v) => (layer as Extract<WatermarkPreset['layers'][number], { type: 'text' }>).align.margin = v"
|
||||
>
|
||||
<template #label>{{ i18n.ts._watermarkEditor.margin }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkRange
|
||||
v-model="layer.scale"
|
||||
:min="0"
|
||||
|
@ -66,6 +78,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
></MkPositionSelector>
|
||||
</FormSlot>
|
||||
|
||||
<MkRange
|
||||
:modelValue="layer.align.margin ?? 0"
|
||||
:min="0"
|
||||
:max="0.25"
|
||||
:step="0.01"
|
||||
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
|
||||
continuousUpdate
|
||||
@update:modelValue="(v) => (layer as Extract<WatermarkPreset['layers'][number], { type: 'image' }>).align.margin = v"
|
||||
>
|
||||
<template #label>{{ i18n.ts._watermarkEditor.margin }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkRange
|
||||
v-model="layer.scale"
|
||||
:min="0"
|
||||
|
@ -107,6 +131,55 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkSwitch>
|
||||
</template>
|
||||
|
||||
<template v-else-if="layer.type === 'qr'">
|
||||
<MkInput v-model="layer.data" debounce>
|
||||
<template #label>{{ i18n.ts._watermarkEditor.text }}</template>
|
||||
<template #caption>{{ i18n.ts._watermarkEditor.leaveBlankToAccountUrl }}</template>
|
||||
</MkInput>
|
||||
|
||||
<FormSlot>
|
||||
<template #label>{{ i18n.ts._watermarkEditor.position }}</template>
|
||||
<MkPositionSelector
|
||||
v-model:x="layer.align.x"
|
||||
v-model:y="layer.align.y"
|
||||
></MkPositionSelector>
|
||||
</FormSlot>
|
||||
|
||||
<MkRange
|
||||
:modelValue="layer.align.margin ?? 0"
|
||||
:min="0"
|
||||
:max="0.25"
|
||||
:step="0.01"
|
||||
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
|
||||
continuousUpdate
|
||||
@update:modelValue="(v) => (layer as Extract<WatermarkPreset['layers'][number], { type: 'qr' }>).align.margin = v"
|
||||
>
|
||||
<template #label>{{ i18n.ts._watermarkEditor.margin }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkRange
|
||||
v-model="layer.scale"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
|
||||
continuousUpdate
|
||||
>
|
||||
<template #label>{{ i18n.ts._watermarkEditor.scale }}</template>
|
||||
</MkRange>
|
||||
|
||||
<MkRange
|
||||
v-model="layer.opacity"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
|
||||
continuousUpdate
|
||||
>
|
||||
<template #label>{{ i18n.ts._watermarkEditor.opacity }}</template>
|
||||
</MkRange>
|
||||
</template>
|
||||
|
||||
<template v-else-if="layer.type === 'stripe'">
|
||||
<MkRange
|
||||
v-model="layer.frequency"
|
||||
|
|
|
@ -30,22 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<div :class="$style.controls">
|
||||
<div class="_spacer _gaps">
|
||||
<MkSelect v-model="type" :items="typeDef">
|
||||
<template #label>{{ i18n.ts._watermarkEditor.type }}</template>
|
||||
</MkSelect>
|
||||
|
||||
<div v-if="type === 'text' || type === 'image'">
|
||||
<XLayer
|
||||
v-for="(layer, i) in preset.layers"
|
||||
:key="layer.id"
|
||||
v-model:layer="preset.layers[i]"
|
||||
></XLayer>
|
||||
</div>
|
||||
<div v-else-if="type === 'advanced'" class="_gaps_s">
|
||||
<div class="_gaps_s">
|
||||
<MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false">
|
||||
<template #label>
|
||||
<div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div>
|
||||
<div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div>
|
||||
<div v-if="layer.type === 'qr'">{{ i18n.ts._watermarkEditor.qr }}</div>
|
||||
<div v-if="layer.type === 'stripe'">{{ i18n.ts._watermarkEditor.stripe }}</div>
|
||||
<div v-if="layer.type === 'polkadot'">{{ i18n.ts._watermarkEditor.polkadot }}</div>
|
||||
<div v-if="layer.type === 'checker'">{{ i18n.ts._watermarkEditor.checker }}</div>
|
||||
|
@ -95,7 +85,7 @@ function createTextLayer(): WatermarkPreset['layers'][number] {
|
|||
id: genId(),
|
||||
type: 'text',
|
||||
text: `(c) @${$i.username}`,
|
||||
align: { x: 'right', y: 'bottom' },
|
||||
align: { x: 'right', y: 'bottom', margin: 0 },
|
||||
scale: 0.3,
|
||||
angle: 0,
|
||||
opacity: 0.75,
|
||||
|
@ -109,7 +99,7 @@ function createImageLayer(): WatermarkPreset['layers'][number] {
|
|||
type: 'image',
|
||||
imageId: null,
|
||||
imageUrl: null,
|
||||
align: { x: 'right', y: 'bottom' },
|
||||
align: { x: 'right', y: 'bottom', margin: 0 },
|
||||
scale: 0.3,
|
||||
angle: 0,
|
||||
opacity: 0.75,
|
||||
|
@ -118,6 +108,17 @@ function createImageLayer(): WatermarkPreset['layers'][number] {
|
|||
};
|
||||
}
|
||||
|
||||
function createQrLayer(): WatermarkPreset['layers'][number] {
|
||||
return {
|
||||
id: genId(),
|
||||
type: 'qr',
|
||||
data: '',
|
||||
align: { x: 'right', y: 'bottom', margin: 0 },
|
||||
scale: 0.3,
|
||||
opacity: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function createStripeLayer(): WatermarkPreset['layers'][number] {
|
||||
return {
|
||||
id: genId(),
|
||||
|
@ -165,7 +166,7 @@ const props = defineProps<{
|
|||
const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? {
|
||||
id: genId(),
|
||||
name: '',
|
||||
layers: [createTextLayer()],
|
||||
layers: [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
@ -187,28 +188,6 @@ async function cancel() {
|
|||
dialog.value?.close();
|
||||
}
|
||||
|
||||
const {
|
||||
model: type,
|
||||
def: typeDef,
|
||||
} = useMkSelect({
|
||||
items: [
|
||||
{ label: i18n.ts._watermarkEditor.text, value: 'text' },
|
||||
{ label: i18n.ts._watermarkEditor.image, value: 'image' },
|
||||
{ label: i18n.ts._watermarkEditor.advanced, value: 'advanced' },
|
||||
],
|
||||
initialValue: preset.layers.length > 1 ? 'advanced' : preset.layers[0].type,
|
||||
});
|
||||
|
||||
watch(type, () => {
|
||||
if (type.value === 'text') {
|
||||
preset.layers = [createTextLayer()];
|
||||
} else if (type.value === 'image') {
|
||||
preset.layers = [createImageLayer()];
|
||||
} else if (type.value === 'advanced') {
|
||||
// nop
|
||||
}
|
||||
});
|
||||
|
||||
watch(preset, async (newValue, oldValue) => {
|
||||
if (renderer != null) {
|
||||
renderer.setLayers(preset.layers);
|
||||
|
@ -338,6 +317,11 @@ function addLayer(ev: MouseEvent) {
|
|||
action: () => {
|
||||
preset.layers.push(createImageLayer());
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._watermarkEditor.qr,
|
||||
action: () => {
|
||||
preset.layers.push(createQrLayer());
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts._watermarkEditor.stripe,
|
||||
action: () => {
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
|
||||
import { expect, userEvent, waitFor, within } from '@storybook/test';
|
||||
import MkAd from './MkAd.vue';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
|
@ -75,6 +75,7 @@ const common = {
|
|||
place: '',
|
||||
imageUrl: '',
|
||||
dayOfWeek: 7,
|
||||
isSensitive: false,
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
|
|
|
@ -43,6 +43,12 @@ const IMAGE_EDITING_SUPPORTED_TYPES = [
|
|||
'image/webp',
|
||||
];
|
||||
|
||||
const VIDEO_COMPRESSION_SUPPORTED_TYPES = [ // TODO
|
||||
'video/mp4',
|
||||
'video/quicktime',
|
||||
'video/x-matroska',
|
||||
];
|
||||
|
||||
const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES;
|
||||
|
||||
const IMAGE_PREPROCESS_NEEDED_TYPES = [
|
||||
|
@ -51,6 +57,10 @@ const IMAGE_PREPROCESS_NEEDED_TYPES = [
|
|||
...IMAGE_EDITING_SUPPORTED_TYPES,
|
||||
];
|
||||
|
||||
const VIDEO_PREPROCESS_NEEDED_TYPES = [
|
||||
...VIDEO_COMPRESSION_SUPPORTED_TYPES,
|
||||
];
|
||||
|
||||
const mimeTypeMap = {
|
||||
'image/webp': 'webp',
|
||||
'image/jpeg': 'jpg',
|
||||
|
@ -64,6 +74,7 @@ export type UploaderItem = {
|
|||
progress: { max: number; value: number } | null;
|
||||
thumbnail: string | null;
|
||||
preprocessing: boolean;
|
||||
preprocessProgress: number | null;
|
||||
uploading: boolean;
|
||||
uploaded: Misskey.entities.DriveFile | null;
|
||||
uploadFailed: boolean;
|
||||
|
@ -76,6 +87,7 @@ export type UploaderItem = {
|
|||
isSensitive?: boolean;
|
||||
caption?: string | null;
|
||||
abort?: (() => void) | null;
|
||||
abortPreprocess?: (() => void) | null;
|
||||
};
|
||||
|
||||
function getCompressionSettings(level: 0 | 1 | 2 | 3) {
|
||||
|
@ -129,11 +141,12 @@ export function useUploader(options: {
|
|||
progress: null,
|
||||
thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? window.URL.createObjectURL(file) : null,
|
||||
preprocessing: false,
|
||||
preprocessProgress: null,
|
||||
uploading: false,
|
||||
aborted: false,
|
||||
uploaded: null,
|
||||
uploadFailed: false,
|
||||
compressionLevel: prefer.s.defaultImageCompressionLevel,
|
||||
compressionLevel: IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultImageCompressionLevel : VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultVideoCompressionLevel : 0,
|
||||
watermarkPresetId: uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? prefer.s.defaultWatermarkPresetId : null,
|
||||
file: markRaw(file),
|
||||
});
|
||||
|
@ -318,7 +331,7 @@ export function useUploader(options: {
|
|||
}
|
||||
|
||||
if (
|
||||
IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) &&
|
||||
(IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) || VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type)) &&
|
||||
!item.preprocessing &&
|
||||
!item.uploading &&
|
||||
!item.uploaded
|
||||
|
@ -391,6 +404,19 @@ export function useUploader(options: {
|
|||
removeItem(item);
|
||||
},
|
||||
});
|
||||
} else if (item.preprocessing && item.abortPreprocess != null) {
|
||||
menu.push({
|
||||
type: 'divider',
|
||||
}, {
|
||||
icon: 'ti ti-player-stop',
|
||||
text: i18n.ts.abort,
|
||||
danger: true,
|
||||
action: () => {
|
||||
if (item.abortPreprocess != null) {
|
||||
item.abortPreprocess();
|
||||
}
|
||||
},
|
||||
});
|
||||
} else if (item.uploading) {
|
||||
menu.push({
|
||||
type: 'divider',
|
||||
|
@ -474,6 +500,10 @@ export function useUploader(options: {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (item.abortPreprocess != null) {
|
||||
item.abortPreprocess();
|
||||
}
|
||||
|
||||
if (item.abort != null) {
|
||||
item.abort();
|
||||
}
|
||||
|
@ -484,18 +514,30 @@ export function useUploader(options: {
|
|||
|
||||
async function preprocess(item: UploaderItem): Promise<void> {
|
||||
item.preprocessing = true;
|
||||
item.preprocessProgress = null;
|
||||
|
||||
try {
|
||||
if (IMAGE_PREPROCESS_NEEDED_TYPES.includes(item.file.type)) {
|
||||
if (IMAGE_PREPROCESS_NEEDED_TYPES.includes(item.file.type)) {
|
||||
try {
|
||||
await preprocessForImage(item);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to preprocess image', err);
|
||||
} catch (err) {
|
||||
console.error('Failed to preprocess image', err);
|
||||
|
||||
// nop
|
||||
}
|
||||
}
|
||||
|
||||
if (VIDEO_PREPROCESS_NEEDED_TYPES.includes(item.file.type)) {
|
||||
try {
|
||||
await preprocessForVideo(item);
|
||||
} catch (err) {
|
||||
console.error('Failed to preprocess video', err);
|
||||
|
||||
// nop
|
||||
}
|
||||
}
|
||||
|
||||
item.preprocessing = false;
|
||||
item.preprocessProgress = null;
|
||||
}
|
||||
|
||||
async function preprocessForImage(item: UploaderItem): Promise<void> {
|
||||
|
@ -564,10 +606,74 @@ export function useUploader(options: {
|
|||
item.preprocessedFile = markRaw(preprocessedFile);
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
async function preprocessForVideo(item: UploaderItem): Promise<void> {
|
||||
let preprocessedFile: Blob | File = item.file;
|
||||
|
||||
const needsCompress = item.compressionLevel !== 0 && VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(preprocessedFile.type);
|
||||
|
||||
if (needsCompress) {
|
||||
const mediabunny = await import('mediabunny');
|
||||
|
||||
const source = new mediabunny.BlobSource(preprocessedFile);
|
||||
|
||||
const input = new mediabunny.Input({
|
||||
source,
|
||||
formats: mediabunny.ALL_FORMATS,
|
||||
});
|
||||
|
||||
const output = new mediabunny.Output({
|
||||
target: new mediabunny.BufferTarget(),
|
||||
format: new mediabunny.Mp4OutputFormat(),
|
||||
});
|
||||
|
||||
const currentConversion = await mediabunny.Conversion.init({
|
||||
input,
|
||||
output,
|
||||
video: {
|
||||
//width: 320, // Height will be deduced automatically to retain aspect ratio
|
||||
bitrate: item.compressionLevel === 1 ? mediabunny.QUALITY_VERY_HIGH : item.compressionLevel === 2 ? mediabunny.QUALITY_MEDIUM : mediabunny.QUALITY_VERY_LOW,
|
||||
},
|
||||
audio: {
|
||||
bitrate: item.compressionLevel === 1 ? mediabunny.QUALITY_VERY_HIGH : item.compressionLevel === 2 ? mediabunny.QUALITY_MEDIUM : mediabunny.QUALITY_VERY_LOW,
|
||||
},
|
||||
});
|
||||
|
||||
currentConversion.onProgress = newProgress => item.preprocessProgress = newProgress;
|
||||
|
||||
item.abortPreprocess = () => {
|
||||
item.abortPreprocess = null;
|
||||
currentConversion.cancel();
|
||||
item.preprocessing = false;
|
||||
item.preprocessProgress = null;
|
||||
};
|
||||
|
||||
await currentConversion.execute();
|
||||
|
||||
item.abortPreprocess = null;
|
||||
|
||||
preprocessedFile = new Blob([output.target.buffer!], { type: output.format.mimeType });
|
||||
item.compressedSize = output.target.buffer!.byteLength;
|
||||
item.uploadName = `${item.name}.mp4`;
|
||||
} else {
|
||||
item.compressedSize = null;
|
||||
item.uploadName = item.name;
|
||||
}
|
||||
|
||||
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
|
||||
item.thumbnail = THUMBNAIL_SUPPORTED_TYPES.includes(preprocessedFile.type) ? window.URL.createObjectURL(preprocessedFile) : null;
|
||||
item.preprocessedFile = markRaw(preprocessedFile);
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
for (const item of items.value) {
|
||||
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
|
||||
}
|
||||
|
||||
abortAll();
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
dispose();
|
||||
});
|
||||
|
||||
return {
|
||||
|
@ -575,6 +681,7 @@ export function useUploader(options: {
|
|||
addFiles,
|
||||
removeItem,
|
||||
abortAll,
|
||||
dispose,
|
||||
upload,
|
||||
getMenu,
|
||||
uploading: computed(() => items.value.some(item => item.uploading)),
|
||||
|
|
|
@ -23,6 +23,15 @@ export function setDragData<T extends keyof DragDataMap>(
|
|||
event.dataTransfer.setData(`misskey/${type}`.toLowerCase(), JSON.stringify(data));
|
||||
}
|
||||
|
||||
export function setPlainDragData(
|
||||
event: DragEvent,
|
||||
data: string,
|
||||
) {
|
||||
if (event.dataTransfer == null) return;
|
||||
|
||||
event.dataTransfer.setData('text/plain', data);
|
||||
}
|
||||
|
||||
export function getDragData<T extends keyof DragDataMap>(
|
||||
event: DragEvent,
|
||||
type: T,
|
||||
|
@ -35,6 +44,17 @@ export function getDragData<T extends keyof DragDataMap>(
|
|||
return JSON.parse(data);
|
||||
}
|
||||
|
||||
export function getPlainDragData(
|
||||
event: DragEvent,
|
||||
): string | null {
|
||||
if (event.dataTransfer == null) return null;
|
||||
|
||||
const data = event.dataTransfer.getData('text/plain');
|
||||
if (data == null || data === '') return null;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export function checkDragDataType(
|
||||
event: DragEvent,
|
||||
types: (keyof DragDataMap)[],
|
||||
|
|
|
@ -10,10 +10,6 @@ export const acct = (user: Misskey.Acct) => {
|
|||
return Misskey.acct.toString(user);
|
||||
};
|
||||
|
||||
export const userName = (user: Misskey.entities.User) => {
|
||||
return user.name || user.username;
|
||||
};
|
||||
|
||||
export const userPage = (user: Misskey.Acct, path?: string, absolute = false) => {
|
||||
return `${absolute ? url : ''}/@${acct(user)}${(path ? `/${path}` : '')}`;
|
||||
};
|
||||
|
|
|
@ -66,6 +66,12 @@ export const navbarItemDef = reactive({
|
|||
lookup();
|
||||
},
|
||||
},
|
||||
qr: {
|
||||
title: i18n.ts.qr,
|
||||
icon: 'ti ti-qrcode',
|
||||
show: computed(() => $i != null),
|
||||
to: '/qr',
|
||||
},
|
||||
lists: {
|
||||
title: i18n.ts.lists,
|
||||
icon: 'ti ti-list',
|
||||
|
@ -111,7 +117,7 @@ export const navbarItemDef = reactive({
|
|||
to: '/channels',
|
||||
},
|
||||
chat: {
|
||||
title: i18n.ts.chat,
|
||||
title: i18n.ts.directMessage_short,
|
||||
icon: 'ti ti-messages',
|
||||
to: '/chat',
|
||||
show: computed(() => $i != null && $i.policies.chatAvailability !== 'unavailable'),
|
||||
|
|
|
@ -76,7 +76,7 @@ export const apiWithDialog = (<E extends keyof Misskey.Endpoints>(
|
|||
} else if (err.code === 'ROLE_PERMISSION_DENIED') {
|
||||
title = i18n.ts.permissionDeniedError;
|
||||
text = i18n.ts.permissionDeniedErrorDescription;
|
||||
} else if (err.code.startsWith('TOO_MANY')) {
|
||||
} else if (err.code.startsWith('TOO_MANY')) { // TODO: バックエンドに kind: client/contentsLimitExceeded みたいな感じで送るように統一してもらってそれで判定する
|
||||
title = i18n.ts.youCannotCreateAnymore;
|
||||
text = `${i18n.ts.error}: ${err.id}`;
|
||||
} else if (err.message.startsWith('Unexpected token')) {
|
||||
|
@ -460,7 +460,7 @@ export function inputNumber(props: {
|
|||
});
|
||||
}
|
||||
|
||||
export function inputDate(props: {
|
||||
export function inputDatetime(props: {
|
||||
title?: string;
|
||||
text?: string;
|
||||
placeholder?: string | null;
|
||||
|
@ -475,13 +475,13 @@ export function inputDate(props: {
|
|||
title: props.title,
|
||||
text: props.text,
|
||||
input: {
|
||||
type: 'date',
|
||||
type: 'datetime-local',
|
||||
placeholder: props.placeholder,
|
||||
default: props.default ?? null,
|
||||
},
|
||||
}, {
|
||||
done: result => {
|
||||
resolve(result ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true });
|
||||
resolve(result != null && result.result != null ? { result: new Date(result.result), canceled: false } : { result: undefined, canceled: true });
|
||||
},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
|
|
|
@ -9,21 +9,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSelect v-model="filterType" :items="filterTypeDef" :class="$style.input" @update:modelValue="filterItems">
|
||||
<template #label>{{ i18n.ts.state }}</template>
|
||||
</MkSelect>
|
||||
|
||||
<div>
|
||||
<div v-for="ad in ads" class="_panel _gaps_m" :class="$style.ad">
|
||||
<MkAd v-if="ad.url" :key="ad.id" :specify="ad"/>
|
||||
|
||||
<MkInput v-model="ad.url" type="url">
|
||||
<template #label>URL</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput v-model="ad.imageUrl" type="url">
|
||||
<template #label>{{ i18n.ts.imageUrl }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkRadios v-model="ad.place">
|
||||
<template #label>Form</template>
|
||||
<option value="square">square</option>
|
||||
<option value="horizontal">horizontal</option>
|
||||
<option value="horizontal-big">horizontal-big</option>
|
||||
</MkRadios>
|
||||
|
||||
<!--
|
||||
<div style="margin: 32px 0;">
|
||||
{{ i18n.ts.priority }}
|
||||
|
@ -32,6 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkRadio v-model="ad.priority" value="low">{{ i18n.ts.low }}</MkRadio>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<FormSplit>
|
||||
<MkInput v-model="ad.ratio" type="number">
|
||||
<template #label>{{ i18n.ts.ratio }}</template>
|
||||
|
@ -43,6 +49,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label>{{ i18n.ts.expiration }}</template>
|
||||
</MkInput>
|
||||
</FormSplit>
|
||||
|
||||
<MkSwitch v-model="ad.isSensitive">
|
||||
<template #label>{{ i18n.ts.sensitive }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkFolder>
|
||||
<template #label>{{ i18n.ts.advancedSettings }}</template>
|
||||
<span>
|
||||
|
@ -56,9 +67,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</span>
|
||||
</MkFolder>
|
||||
|
||||
<MkTextarea v-model="ad.memo">
|
||||
<template #label>{{ i18n.ts.memo }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<div class="_buttons">
|
||||
<MkButton inline primary style="margin-right: 12px;" @click="save(ad)">
|
||||
<i
|
||||
|
@ -70,6 +83,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MkButton @click="more()">
|
||||
<i class="ti ti-reload"></i>{{ i18n.ts.more }}
|
||||
</MkButton>
|
||||
|
@ -88,6 +102,7 @@ import MkRadios from '@/components/MkRadios.vue';
|
|||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import FormSplit from '@/components/form/split.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
@ -158,6 +173,7 @@ function add() {
|
|||
expiresAt: new Date().toISOString(),
|
||||
startsAt: new Date().toISOString(),
|
||||
dayOfWeek: 0,
|
||||
isSensitive: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -802,6 +802,25 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.scheduledNoteLimit, 'scheduledNoteLimit'])">
|
||||
<template #label>{{ i18n.ts._role._options.scheduledNoteLimit }}</template>
|
||||
<template #suffix>
|
||||
<span v-if="role.policies.scheduledNoteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
|
||||
<span v-else>{{ role.policies.scheduledNoteLimit.value }}</span>
|
||||
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.scheduledNoteLimit)"></i></span>
|
||||
</template>
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="role.policies.scheduledNoteLimit.useDefault" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
|
||||
</MkSwitch>
|
||||
<MkInput v-model="role.policies.scheduledNoteLimit.value" :disabled="role.policies.scheduledNoteLimit.useDefault" type="number" :readonly="readonly">
|
||||
</MkInput>
|
||||
<MkRange v-model="role.policies.scheduledNoteLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
|
||||
<template #label>{{ i18n.ts._role.priority }}</template>
|
||||
</MkRange>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.watermarkAvailable, 'watermarkAvailable'])">
|
||||
<template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template>
|
||||
<template #suffix>
|
||||
|
@ -831,6 +850,7 @@ import { watch, ref, computed } from 'vue';
|
|||
import { throttle } from 'throttle-debounce';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import RolesEditorFormula from './RolesEditorFormula.vue';
|
||||
import type { MkSelectItem, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkColorInput from '@/components/MkColorInput.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
|
@ -842,7 +862,6 @@ import FormSlot from '@/components/form/slot.vue';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { deepClone } from '@/utility/clone.js';
|
||||
import type { MkSelectItem, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'update:modelValue', v: any): void;
|
||||
|
|
|
@ -304,6 +304,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkInput>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.scheduledNoteLimit, 'scheduledNoteLimit'])">
|
||||
<template #label>{{ i18n.ts._role._options.scheduledNoteLimit }}</template>
|
||||
<template #suffix>{{ policies.scheduledNoteLimit }}</template>
|
||||
<MkInput v-model="policies.scheduledNoteLimit" type="number" :min="0">
|
||||
</MkInput>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.watermarkAvailable, 'watermarkAvailable'])">
|
||||
<template #label>{{ i18n.ts._role._options.watermarkAvailable }}</template>
|
||||
<template #suffix>{{ policies.watermarkAvailable ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||
|
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true">
|
||||
<MkPolkadots v-if="tab === 'home'" accented/>
|
||||
<MkPolkadots v-if="tab === 'home'" accented :height="200" style="margin-bottom: -200px;"/>
|
||||
<div class="_spacer" style="--MI_SPACER-w: 700px;">
|
||||
<XHome v-if="tab === 'home'"/>
|
||||
<XInvitations v-else-if="tab === 'invitations'"/>
|
||||
|
@ -48,7 +48,7 @@ const headerTabs = computed(() => [{
|
|||
}]);
|
||||
|
||||
definePage(() => ({
|
||||
title: i18n.ts.chat + ' (beta)',
|
||||
title: i18n.ts.directMessage,
|
||||
icon: 'ti ti-messages',
|
||||
}));
|
||||
</script>
|
||||
|
|
|
@ -46,6 +46,6 @@ onMounted(() => {
|
|||
});
|
||||
|
||||
definePage({
|
||||
title: i18n.ts.chat,
|
||||
title: i18n.ts.directMessage,
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -421,7 +421,7 @@ const tab = ref('chat');
|
|||
|
||||
const headerTabs = computed(() => room.value ? [{
|
||||
key: 'chat',
|
||||
title: i18n.ts.chat,
|
||||
title: i18n.ts._chat.messages,
|
||||
icon: 'ti ti-messages',
|
||||
}, {
|
||||
key: 'members',
|
||||
|
@ -437,7 +437,7 @@ const headerTabs = computed(() => room.value ? [{
|
|||
icon: 'ti ti-info-circle',
|
||||
}] : [{
|
||||
key: 'chat',
|
||||
title: i18n.ts.chat,
|
||||
title: i18n.ts._chat.messages,
|
||||
icon: 'ti ti-messages',
|
||||
}, {
|
||||
key: 'search',
|
||||
|
@ -466,12 +466,12 @@ definePage(computed(() => {
|
|||
};
|
||||
} else {
|
||||
return {
|
||||
title: i18n.ts.chat,
|
||||
title: i18n.ts.directMessage,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
title: i18n.ts.chat,
|
||||
title: i18n.ts.directMessage,
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
|
|
@ -136,10 +136,10 @@ function fetchNote() {
|
|||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
if (err.id === '8e75455b-738c-471d-9f80-62693f33372e') {
|
||||
if (['fbcc002d-37d9-4944-a6b0-d9e29f2d33ab', '145f88d2-b03d-4087-8143-a78928883c4b'].includes(err.id)) {
|
||||
pleaseLogin({
|
||||
path: '/',
|
||||
message: i18n.ts.thisContentsAreMarkedAsSigninRequiredByAuthor,
|
||||
message: err.id === 'fbcc002d-37d9-4944-a6b0-d9e29f2d33ab' ? i18n.ts.thisContentsAreMarkedAsSigninRequiredByAuthor : i18n.ts.signinOrContinueOnRemote,
|
||||
openOnRemote: {
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${props.noteId}`,
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkFolder defaultOpen :withSpacer="false">
|
||||
<template #label>{{ data.split('\n')[0] }}</template>
|
||||
<template #header>
|
||||
<MkTabs
|
||||
v-model:tab="tab"
|
||||
:tabs="[
|
||||
{
|
||||
key: 'mfm',
|
||||
title: i18n.ts._qr.mfm,
|
||||
icon: 'ti ti-align-left',
|
||||
},
|
||||
{
|
||||
key: 'raw',
|
||||
title: i18n.ts._qr.raw,
|
||||
icon: 'ti ti-code',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div v-show="tab === 'mfm'" class="_spacer _gaps">
|
||||
<Mfm :text="data" :nyaize="false"/>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false"/>
|
||||
</div>
|
||||
<div v-show="tab === 'raw'" class="_spacer" style="--MI_SPACER-min: 10px; --MI_SPACER-max: 16px;">
|
||||
<MkCode :code="data" lang="text"/>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkTabs from '@/components/MkTabs.vue';
|
||||
import { extractUrlFromMfm } from '@/utility/extract-url-from-mfm';
|
||||
import MkCode from '@/components/MkCode.vue';
|
||||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = defineProps<{
|
||||
data: string;
|
||||
}>();
|
||||
|
||||
const parsed = computed(() => mfm.parse(props.data));
|
||||
const urls = computed(() => extractUrlFromMfm(parsed.value));
|
||||
const tab = ref<'mfm' | 'raw'>('mfm');
|
||||
</script>
|
|
@ -0,0 +1,397 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
:class="$style.root"
|
||||
:style="{
|
||||
'--MI-QrReadViewHeight': 'calc(100cqh - var(--MI-stickyTop, 0px) - var(--MI-stickyBottom, 0px))',
|
||||
'--MI-QrReadVideoHeight': 'min(calc(var(--MI-QrReadViewHeight) * 0.3), 512px)',
|
||||
}"
|
||||
>
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<div :class="$style.view">
|
||||
<video ref="videoEl" :class="$style.video" autoplay muted playsinline></video>
|
||||
<div ref="overlayEl" :class="$style.overlay"></div>
|
||||
<div :class="$style.controls">
|
||||
<MkButton v-tooltip="i18n.ts._qr.scanFile" iconOnly @click="upload"><i class="ti ti-photo-plus"></i></MkButton>
|
||||
|
||||
<MkButton v-if="qrStarted" v-tooltip="i18n.ts._qr.stopQr" iconOnly @click="stopQr"><i class="ti ti-player-play"></i></MkButton>
|
||||
<MkButton v-else v-tooltip="i18n.ts._qr.startQr" iconOnly danger @click="startQr"><i class="ti ti-player-pause"></i></MkButton>
|
||||
|
||||
<MkButton v-tooltip="i18n.ts._qr.chooseCamera" iconOnly @click="chooseCamera"><i class="ti ti-camera-rotate"></i></MkButton>
|
||||
|
||||
<MkButton v-if="!flashCanToggle" v-tooltip="i18n.ts._qr.cannotToggleFlash" iconOnly disabled><i class="ti ti-bolt"></i></MkButton>
|
||||
<MkButton v-else-if="!flash" v-tooltip="i18n.ts._qr.turnOnFlash" iconOnly @click="toggleFlash(true)"><i class="ti ti-bolt-off"></i></MkButton>
|
||||
<MkButton v-else v-tooltip="i18n.ts._qr.turnOffFlash" iconOnly @click="toggleFlash(false)"><i class="ti ti-bolt-filled"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
:class="['_spacer', $style.contents]"
|
||||
:style="{
|
||||
'--MI_SPACER-w': '800px'
|
||||
}"
|
||||
>
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<MkTab v-model="tab" :class="$style.tab">
|
||||
<option value="users">{{ i18n.ts.users }}</option>
|
||||
<option value="notes">{{ i18n.ts.notes }}</option>
|
||||
<option value="all">{{ i18n.ts.all }}</option>
|
||||
</MkTab>
|
||||
</template>
|
||||
<div v-if="tab === 'users'" :class="[$style.users, '_margin']" style="padding-bottom: var(--MI-margin);">
|
||||
<MkUserInfo v-for="user in users" :key="user.id" :user="user"/>
|
||||
</div>
|
||||
<div v-else-if="tab === 'notes'" class="_margin _gaps" style="padding-bottom: var(--MI-margin);">
|
||||
<MkNote v-for="note in notes" :key="note.id" :note="note" :class="$style.note"/>
|
||||
</div>
|
||||
<div v-else-if="tab === 'all'" class="_margin _gaps" style="padding-bottom: var(--MI-margin);">
|
||||
<MkQrReadRawViewer v-for="result in Array.from(results).reverse()" :key="result" :data="result"/>
|
||||
</div>
|
||||
</MkStickyContainer>
|
||||
</div>
|
||||
</MkStickyContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import QrScanner from 'qr-scanner';
|
||||
import { onActivated, onDeactivated, onMounted, onUnmounted, ref, shallowRef, useTemplateRef, watch } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import { getScrollContainer } from '@@/js/scroll.js';
|
||||
import type { ApShowResponse } from 'misskey-js/entities.js';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkUserInfo from '@/components/MkUserInfo.vue';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import MkNote from '@/components/MkNote.vue';
|
||||
import MkTab from '@/components/MkTab.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkQrReadRawViewer from '@/pages/qr.read.raw-viewer.vue';
|
||||
|
||||
const LIST_RERENDER_INTERVAL = 1500;
|
||||
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const videoEl = useTemplateRef('videoEl');
|
||||
const overlayEl = useTemplateRef('overlayEl');
|
||||
|
||||
const scannerInstance = shallowRef<QrScanner | null>(null);
|
||||
|
||||
const tab = ref<'users' | 'notes' | 'all'>('users');
|
||||
|
||||
// higher is recent
|
||||
const results = ref(new Set<string>());
|
||||
// lower is recent
|
||||
const uris = ref<string[]>([]);
|
||||
const sources = new Map<string, ApShowResponse | null>();
|
||||
const users = ref<(misskey.entities.UserDetailed)[]>([]);
|
||||
const usersCount = ref(0);
|
||||
const notes = ref<misskey.entities.Note[]>([]);
|
||||
const notesCount = ref(0);
|
||||
|
||||
const timer = ref<number | null>(null);
|
||||
|
||||
function updateLists() {
|
||||
const responses = uris.value.map(uri => sources.get(uri)).filter((r): r is ApShowResponse => !!r);
|
||||
users.value = responses.filter(r => r.type === 'User').map(r => r.object).filter((u): u is misskey.entities.UserDetailed => !!u);
|
||||
usersCount.value = users.value.length;
|
||||
notes.value = responses.filter(r => r.type === 'Note').map(r => r.object).filter((n): n is misskey.entities.Note => !!n);
|
||||
notesCount.value = notes.value.length;
|
||||
updateRequired.value = false;
|
||||
}
|
||||
|
||||
const updateRequired = ref(false);
|
||||
|
||||
watch(uris, () => {
|
||||
if (timer.value) {
|
||||
updateRequired.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
updateLists();
|
||||
|
||||
timer.value = window.setTimeout(() => {
|
||||
timer.value = null;
|
||||
if (updateRequired.value) {
|
||||
updateLists();
|
||||
}
|
||||
}, LIST_RERENDER_INTERVAL) as number;
|
||||
});
|
||||
|
||||
watch(tab, () => {
|
||||
if (timer.value) {
|
||||
window.clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
updateLists();
|
||||
});
|
||||
|
||||
async function processResult(result: QrScanner.ScanResult) {
|
||||
if (!result) return;
|
||||
const trimmed = result.data.trim();
|
||||
|
||||
if (!trimmed) return;
|
||||
|
||||
const haveExisted = results.value.has(trimmed);
|
||||
results.value.add(trimmed);
|
||||
|
||||
try {
|
||||
new URL(trimmed);
|
||||
} catch {
|
||||
if (!haveExisted) {
|
||||
tab.value = 'all';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (uris.value[0] !== trimmed) {
|
||||
// 並べ替え
|
||||
uris.value = [trimmed, ...uris.value.slice(0, 29).filter(u => u !== trimmed)];
|
||||
}
|
||||
|
||||
if (sources.has(trimmed)) return;
|
||||
// Start fetching user info
|
||||
sources.set(trimmed, null);
|
||||
|
||||
await misskeyApi('ap/show', { uri: trimmed })
|
||||
.then(data => {
|
||||
if (data.type === 'User') {
|
||||
sources.set(trimmed, data);
|
||||
tab.value = 'users';
|
||||
} else if (data.type === 'Note') {
|
||||
sources.set(trimmed, data);
|
||||
tab.value = 'notes';
|
||||
}
|
||||
updateLists();
|
||||
})
|
||||
.catch(err => {
|
||||
tab.value = 'all';
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
const qrStarted = ref(true);
|
||||
const flashCanToggle = ref(false);
|
||||
const flash = ref(false);
|
||||
|
||||
async function upload() {
|
||||
os.chooseFileFromPc({ multiple: true }).then(files => {
|
||||
if (files.length === 0) return;
|
||||
for (const file of files) {
|
||||
QrScanner.scanImage(file, { returnDetailedScanResult: true })
|
||||
.then(result => {
|
||||
processResult(result);
|
||||
})
|
||||
.catch(err => {
|
||||
if (err.toString().includes('No QR code found')) {
|
||||
os.alert({
|
||||
type: 'info',
|
||||
text: i18n.ts._qr.noQrCodeFound,
|
||||
});
|
||||
} else {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: err.toString(),
|
||||
});
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function chooseCamera() {
|
||||
if (!scannerInstance.value) return;
|
||||
const cameras = await QrScanner.listCameras(true);
|
||||
if (cameras.length === 0) {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const select = await os.select({
|
||||
title: i18n.ts._qr.chooseCamera,
|
||||
items: cameras.map(camera => ({
|
||||
label: camera.label,
|
||||
value: camera.id,
|
||||
})),
|
||||
});
|
||||
if (select.canceled) return;
|
||||
if (select.result == null) return;
|
||||
|
||||
await scannerInstance.value.setCamera(select.result);
|
||||
flashCanToggle.value = await scannerInstance.value.hasFlash();
|
||||
flash.value = scannerInstance.value.isFlashOn();
|
||||
}
|
||||
|
||||
async function toggleFlash(to = false) {
|
||||
if (!scannerInstance.value) return;
|
||||
|
||||
flash.value = to;
|
||||
if (flash.value) {
|
||||
await scannerInstance.value.turnFlashOn();
|
||||
} else {
|
||||
await scannerInstance.value.turnFlashOff();
|
||||
}
|
||||
}
|
||||
|
||||
async function startQr() {
|
||||
if (!scannerInstance.value) return;
|
||||
await scannerInstance.value.start();
|
||||
qrStarted.value = true;
|
||||
}
|
||||
|
||||
function stopQr() {
|
||||
if (!scannerInstance.value) return;
|
||||
scannerInstance.value.stop();
|
||||
qrStarted.value = false;
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
startQr;
|
||||
});
|
||||
|
||||
onDeactivated(() => {
|
||||
stopQr;
|
||||
});
|
||||
|
||||
const alertLock = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
if (!videoEl.value || !overlayEl.value) {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.somethingHappened,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
scannerInstance.value = new QrScanner(
|
||||
videoEl.value,
|
||||
processResult,
|
||||
{
|
||||
highlightScanRegion: true,
|
||||
highlightCodeOutline: true,
|
||||
overlay: overlayEl.value,
|
||||
calculateScanRegion(video: HTMLVideoElement): QrScanner.ScanRegion {
|
||||
const aspectRatio = video.videoWidth / video.videoHeight;
|
||||
const SHORT_SIDE_SIZE_DOWNSCALED = 360;
|
||||
return {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
downScaledWidth: aspectRatio > 1 ? Math.round(SHORT_SIDE_SIZE_DOWNSCALED * aspectRatio) : SHORT_SIDE_SIZE_DOWNSCALED,
|
||||
downScaledHeight: aspectRatio > 1 ? SHORT_SIDE_SIZE_DOWNSCALED : Math.round(SHORT_SIDE_SIZE_DOWNSCALED / aspectRatio),
|
||||
};
|
||||
},
|
||||
onDecodeError(err) {
|
||||
if (err.toString().includes('No QR code found')) return;
|
||||
if (alertLock.value) return;
|
||||
alertLock.value = true;
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: err.toString(),
|
||||
}).finally(() => {
|
||||
alertLock.value = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
scannerInstance.value.start()
|
||||
.then(async () => {
|
||||
qrStarted.value = true;
|
||||
if (!scannerInstance.value) return;
|
||||
flashCanToggle.value = await scannerInstance.value.hasFlash();
|
||||
flash.value = scannerInstance.value.isFlashOn();
|
||||
})
|
||||
.catch(err => {
|
||||
qrStarted.value = false;
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: err.toString(),
|
||||
});
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
window.clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
|
||||
scannerInstance.value?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.view {
|
||||
position: sticky;
|
||||
top: var(--MI-stickyTop, 0);
|
||||
z-index: 1;
|
||||
background: var(--MI_THEME-bg);
|
||||
background-size: 16px 16px;
|
||||
width: 100%;
|
||||
height: var(--MI-QrReadVideoHeight);
|
||||
}
|
||||
|
||||
.video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.controls {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
html[data-color-scheme=dark] .view {
|
||||
--c: rgb(255 255 255 / 2%);
|
||||
background-image: linear-gradient(45deg, var(--c) 16.67%, var(--MI_THEME-bg) 16.67%, var(--MI_THEME-bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--MI_THEME-bg) 66.67%, var(--MI_THEME-bg) 100%);
|
||||
}
|
||||
|
||||
html[data-color-scheme=light] .view {
|
||||
--c: rgb(0 0 0 / 2%);
|
||||
background-image: linear-gradient(45deg, var(--c) 16.67%, var(--MI_THEME-bg) 16.67%, var(--MI_THEME-bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--MI_THEME-bg) 66.67%, var(--MI_THEME-bg) 100%);
|
||||
}
|
||||
|
||||
.contents {
|
||||
padding-top: calc(var(--MI-margin) / 2);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: calc(var(--MI-margin) / 2) 0;
|
||||
background: var(--MI_THEME-bg);
|
||||
}
|
||||
|
||||
.users {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
grid-gap: var(--MI-margin);
|
||||
}
|
||||
|
||||
.note {
|
||||
background: var(--MI_THEME-panel);
|
||||
border-radius: var(--MI-radius);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,234 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<div :class="[$style.content]">
|
||||
<div
|
||||
ref="qrCodeEl" v-flip :style="{
|
||||
'cursor': canShare ? 'pointer' : 'default',
|
||||
}"
|
||||
:class="$style.qr" @click="share"
|
||||
></div>
|
||||
<div v-flip :class="$style.user">
|
||||
<MkAvatar :class="$style.avatar" :user="$i" :indicator="false"/>
|
||||
<div>
|
||||
<div :class="$style.name"><MkCondensedLine :minScale="2 / 3"><MkUserName :user="$i" :nowrap="true"/></MkCondensedLine></div>
|
||||
<div><MkCondensedLine :minScale="2 / 3">{{ acct }}</MkCondensedLine></div>
|
||||
</div>
|
||||
</div>
|
||||
<img v-if="deviceMotionPermissionNeeded" v-flip :class="$style.logo" :src="misskeysvg" alt="Misskey Logo" @click="requestDeviceMotion"/>
|
||||
<img v-else v-flip :class="$style.logo" :src="misskeysvg" alt="Misskey Logo"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import tinycolor from 'tinycolor2';
|
||||
import QRCodeStyling from 'qr-code-styling';
|
||||
import { computed, ref, shallowRef, watch, onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import { url, host } from '@@/js/config.js';
|
||||
import type { Directive } from 'vue';
|
||||
import { instance } from '@/instance.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { userPage, userName } from '@/filters/user.js';
|
||||
import misskeysvg from '/client-assets/misskey.svg';
|
||||
import { getStaticImageUrl } from '@/utility/media-proxy.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
||||
const acct = computed(() => `@${$i.username}@${host}`);
|
||||
const userProfileUrl = computed(() => userPage($i, undefined, true));
|
||||
const shareData = computed(() => ({
|
||||
title: i18n.tsx._qr.shareTitle({ name: userName($i), acct: acct.value }),
|
||||
text: i18n.ts._qr.shareText,
|
||||
url: userProfileUrl.value,
|
||||
}));
|
||||
const canShare = computed(() => navigator.canShare && navigator.canShare(shareData.value));
|
||||
|
||||
const qrCodeEl = useTemplateRef('qrCodeEl');
|
||||
|
||||
const qrColor = computed(() => tinycolor(instance.themeColor ?? '#86b300'));
|
||||
const qrHsl = computed(() => qrColor.value.toHsl());
|
||||
|
||||
function share() {
|
||||
if (!canShare.value) return;
|
||||
return navigator.share(shareData.value);
|
||||
}
|
||||
|
||||
const qrCodeInstance = new QRCodeStyling({
|
||||
width: 600,
|
||||
height: 600,
|
||||
margin: 42,
|
||||
type: 'canvas',
|
||||
data: `${url}/users/${$i.id}`,
|
||||
image: instance.iconUrl ? getStaticImageUrl(instance.iconUrl) : '/favicon.ico',
|
||||
qrOptions: {
|
||||
typeNumber: 0,
|
||||
mode: 'Byte',
|
||||
errorCorrectionLevel: 'H',
|
||||
},
|
||||
imageOptions: {
|
||||
hideBackgroundDots: true,
|
||||
imageSize: 0.3,
|
||||
margin: 16,
|
||||
crossOrigin: 'anonymous',
|
||||
},
|
||||
dotsOptions: {
|
||||
type: 'dots',
|
||||
color: tinycolor(`hsl(${qrHsl.value.h}, 100, 18)`).toRgbString(),
|
||||
},
|
||||
cornersDotOptions: {
|
||||
type: 'dot',
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: 'extra-rounded',
|
||||
},
|
||||
backgroundOptions: {
|
||||
color: tinycolor(`hsl(${qrHsl.value.h}, 100, 97)`).toRgbString(),
|
||||
},
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (qrCodeEl.value != null) {
|
||||
qrCodeInstance.append(qrCodeEl.value);
|
||||
}
|
||||
});
|
||||
|
||||
//#region flip
|
||||
const THRESHOLD = -3;
|
||||
// @ts-expect-error TS(2339)
|
||||
const deviceMotionPermissionNeeded = window.DeviceMotionEvent && typeof window.DeviceMotionEvent.requestPermission === 'function';
|
||||
const flipEls: Set<Element> = new Set();
|
||||
const flip = ref(false);
|
||||
|
||||
function handleOrientationChange(event: DeviceOrientationEvent) {
|
||||
const isUpsideDown = event.beta ? event.beta < THRESHOLD : false;
|
||||
flip.value = isUpsideDown;
|
||||
}
|
||||
|
||||
watch(flip, (newState) => {
|
||||
flipEls.forEach(el => {
|
||||
el.classList.toggle('_qrShowFlipFliped', newState);
|
||||
});
|
||||
});
|
||||
|
||||
function requestDeviceMotion() {
|
||||
if (!deviceMotionPermissionNeeded) return;
|
||||
// @ts-expect-error TS(2339)
|
||||
window.DeviceMotionEvent.requestPermission()
|
||||
.then((response: string) => {
|
||||
if (response === 'granted') {
|
||||
window.addEventListener('deviceorientation', handleOrientationChange);
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('deviceorientation', handleOrientationChange);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('deviceorientation', handleOrientationChange);
|
||||
});
|
||||
|
||||
const vFlip = {
|
||||
mounted(el: Element) {
|
||||
flipEls.add(el);
|
||||
el.classList.add('_qrShowFlip');
|
||||
},
|
||||
unmounted(el: Element) {
|
||||
el.classList.remove('_qrShowFlip');
|
||||
flipEls.delete(el);
|
||||
},
|
||||
} as Directive;
|
||||
//#endregion
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
$s1: 14px;
|
||||
$s2: 21px;
|
||||
$s3: 28px;
|
||||
$avatarSize: 58px;
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qr {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: 230px;
|
||||
border-radius: 12px;
|
||||
overflow: clip;
|
||||
aspect-ratio: 1;
|
||||
|
||||
> svg,
|
||||
> canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: $s3 auto;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
overflow: visible;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: $avatarSize;
|
||||
height: $avatarSize;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: bold;
|
||||
font-size: 110%;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 100px;
|
||||
margin: $s3 auto 0;
|
||||
filter: drop-shadow(0 0 6px #0007);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
/*
|
||||
* useCssModuleで$styleを読み込みたかったが、rollupでのunwindが壊れてしまうらしく失敗。
|
||||
* グローバルにクラスを定義することでお茶を濁す。
|
||||
*/
|
||||
._qrShowFlip {
|
||||
transition: rotate .3s linear, scale .3s .15s step-start;
|
||||
}
|
||||
|
||||
._qrShowFlipFliped {
|
||||
scale: -1 1;
|
||||
rotate: x 180deg;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,57 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root" class="_pageScrollable">
|
||||
<div class="_spacer" :class="$style.main">
|
||||
<MkButton v-if="read" :class="$style.button" rounded @click="read = false"><i class="ti ti-qrcode"></i> {{ i18n.ts._qr.showTabTitle }}</MkButton>
|
||||
<MkButton v-else :class="$style.button" rounded @click="read = true"><i class="ti ti-scan"></i> {{ i18n.ts._qr.readTabTitle }}</MkButton>
|
||||
|
||||
<MkQrRead v-if="read"/>
|
||||
<MkQrShow v-else/>
|
||||
</div>
|
||||
<MkPolkadots v-if="!read" accented revered :height="200" style="position: sticky; bottom: 0; margin-top: -200px;"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, ref, shallowRef } from 'vue';
|
||||
import MkQrShow from './qr.show.vue';
|
||||
import { definePage } from '@/page.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { ensureSignin } from '@/i';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkPolkadots from '@/components/MkPolkadots.vue';
|
||||
|
||||
// router definitionでloginRequiredが設定されているためエラーハンドリングしない
|
||||
const $i = ensureSignin();
|
||||
|
||||
const read = ref(false);
|
||||
|
||||
const MkQrRead = defineAsyncComponent(() => import('./qr.read.vue'));
|
||||
|
||||
definePage(() => ({
|
||||
title: i18n.ts.qr,
|
||||
icon: 'ti ti-qrcode',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin: 0 auto 16px auto;
|
||||
}
|
||||
</style>
|
|
@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<FormLink @click="chooseUploadFolder()">
|
||||
<SearchLabel>{{ i18n.ts.uploadFolder }}</SearchLabel>
|
||||
<template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template>
|
||||
<template #suffixIcon><i class="ti ti-folder"></i></template>
|
||||
<template #icon><i class="ti ti-folder"></i></template>
|
||||
</FormLink>
|
||||
</SearchMarker>
|
||||
|
||||
|
@ -129,13 +129,37 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSelect
|
||||
v-model="defaultImageCompressionLevel" :items="[
|
||||
{ label: i18n.ts.none, value: 0 },
|
||||
{ label: i18n.ts.low, value: 1 },
|
||||
{ label: i18n.ts.medium, value: 2 },
|
||||
{ label: i18n.ts.high, value: 3 },
|
||||
{ label: `${i18n.ts.low} (${i18n.ts._compression._quality.high}; ${i18n.ts._compression._size.large})`, value: 1 },
|
||||
{ label: `${i18n.ts.medium} (${i18n.ts._compression._quality.medium}; ${i18n.ts._compression._size.medium})`, value: 2 },
|
||||
{ label: `${i18n.ts.high} (${i18n.ts._compression._quality.low}; ${i18n.ts._compression._size.small})`, value: 3 },
|
||||
]"
|
||||
>
|
||||
<template #label><SearchLabel>{{ i18n.ts.defaultImageCompressionLevel }}</SearchLabel></template>
|
||||
<template #caption><div v-html="i18n.ts.defaultImageCompressionLevel_description"></div></template>
|
||||
<template #label><SearchLabel>{{ i18n.ts.defaultCompressionLevel }}</SearchLabel></template>
|
||||
<template #caption><div v-html="i18n.ts.defaultCompressionLevel_description"></div></template>
|
||||
</MkSelect>
|
||||
</MkPreferenceContainer>
|
||||
</SearchMarker>
|
||||
</div>
|
||||
</FormSection>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['video']">
|
||||
<FormSection>
|
||||
<template #label><SearchLabel>{{ i18n.ts.video }}</SearchLabel></template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<SearchMarker :keywords="['default', 'video', 'compression']">
|
||||
<MkPreferenceContainer k="defaultVideoCompressionLevel">
|
||||
<MkSelect
|
||||
v-model="defaultVideoCompressionLevel" :items="[
|
||||
{ label: i18n.ts.none, value: 0 },
|
||||
{ label: `${i18n.ts.low} (${i18n.ts._compression._quality.high}; ${i18n.ts._compression._size.large})`, value: 1 },
|
||||
{ label: `${i18n.ts.medium} (${i18n.ts._compression._quality.medium}; ${i18n.ts._compression._size.medium})`, value: 2 },
|
||||
{ label: `${i18n.ts.high} (${i18n.ts._compression._quality.low}; ${i18n.ts._compression._size.small})`, value: 3 },
|
||||
]"
|
||||
>
|
||||
<template #label><SearchLabel>{{ i18n.ts.defaultCompressionLevel }}</SearchLabel></template>
|
||||
<template #caption><div v-html="i18n.ts.defaultCompressionLevel_description"></div></template>
|
||||
</MkSelect>
|
||||
</MkPreferenceContainer>
|
||||
</SearchMarker>
|
||||
|
@ -196,6 +220,7 @@ const meterStyle = computed(() => {
|
|||
const keepOriginalFilename = prefer.model('keepOriginalFilename');
|
||||
const defaultWatermarkPresetId = prefer.model('defaultWatermarkPresetId');
|
||||
const defaultImageCompressionLevel = prefer.model('defaultImageCompressionLevel');
|
||||
const defaultVideoCompressionLevel = prefer.model('defaultVideoCompressionLevel');
|
||||
|
||||
const watermarkPresetsSyncEnabled = ref(prefer.isSyncEnabled('watermarkPresets'));
|
||||
|
||||
|
|
|
@ -64,6 +64,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<option :value="1">{{ i18n.ts.small }}</option>
|
||||
<option :value="2">{{ i18n.ts.medium }}</option>
|
||||
<option :value="3">{{ i18n.ts.large }}</option>
|
||||
<option :value="4">{{ i18n.ts.large }}+</option>
|
||||
<option :value="5">{{ i18n.ts.large }}++</option>
|
||||
</MkRadios>
|
||||
</MkPreferenceContainer>
|
||||
</SearchMarker>
|
||||
|
@ -95,11 +97,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<SearchMarker :keywords="['emoji', 'picker', 'style']">
|
||||
<MkPreferenceContainer k="emojiPickerStyle">
|
||||
<MkSelect v-model="emojiPickerStyle" :items="[
|
||||
{ label: i18n.ts.auto, value: 'auto' },
|
||||
{ label: i18n.ts.popup, value: 'popup' },
|
||||
{ label: i18n.ts.drawer, value: 'drawer' },
|
||||
]">
|
||||
<MkSelect
|
||||
v-model="emojiPickerStyle" :items="[
|
||||
{ label: i18n.ts.auto, value: 'auto' },
|
||||
{ label: i18n.ts.popup, value: 'popup' },
|
||||
{ label: i18n.ts.drawer, value: 'drawer' },
|
||||
]"
|
||||
>
|
||||
<template #label><SearchLabel>{{ i18n.ts.style }}</SearchLabel></template>
|
||||
<template #caption>{{ i18n.ts.needReloadToApply }}</template>
|
||||
</MkSelect>
|
||||
|
@ -116,13 +120,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import XPalette from './emoji-palette.palette.vue';
|
||||
import type { MkSelectItem } from '@/components/MkSelect.vue';
|
||||
import { genId } from '@/utility/id.js';
|
||||
import MkRadios from '@/components/MkRadios.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import FormSection from '@/components/form/section.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import type { MkSelectItem } from '@/components/MkSelect.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePage } from '@/page.js';
|
||||
|
|
|
@ -414,7 +414,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template v-if="$i.policies.chatAvailability !== 'unavailable'">
|
||||
<SearchMarker v-slot="slotProps" :keywords="['chat', 'messaging']">
|
||||
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
|
||||
<template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template>
|
||||
<template #label><SearchLabel>{{ i18n.ts.directMessage }}</SearchLabel></template>
|
||||
<template #icon><SearchIcon><i class="ti ti-messages"></i></SearchIcon></template>
|
||||
|
||||
<div class="_gaps_s">
|
||||
|
|
|
@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<SearchMarker :keywords="['chat']">
|
||||
<FormSection>
|
||||
<template #label><SearchLabel>{{ i18n.ts.chat }}</SearchLabel></template>
|
||||
<template #label><SearchLabel>{{ i18n.ts.directMessage }}</SearchLabel></template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<MkInfo v-if="$i.policies.chatAvailability === 'unavailable'">{{ i18n.ts._chat.chatNotAvailableForThisAccountOrServer }}</MkInfo>
|
||||
|
|
|
@ -151,6 +151,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</MkFolder>
|
||||
</SearchMarker>
|
||||
|
||||
<hr>
|
||||
|
||||
<SearchMarker :keywords="['qrcode']">
|
||||
<FormLink to="/qr">
|
||||
<template #icon><i class="ti ti-qrcode"></i></template>
|
||||
<SearchLabel>{{ i18n.ts.qr }}</SearchLabel>
|
||||
</FormLink>
|
||||
</SearchMarker>
|
||||
</div>
|
||||
</SearchMarker>
|
||||
</template>
|
||||
|
@ -164,6 +173,7 @@ import MkSelect from '@/components/MkSelect.vue';
|
|||
import FormSplit from '@/components/form/split.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import FormSlot from '@/components/form/slot.vue';
|
||||
import FormLink from '@/components/form/link.vue';
|
||||
import { chooseDriveFile } from '@/utility/drive.js';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
|
|
@ -5,7 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<SearchMarker path="/settings/theme" :label="i18n.ts.theme" :keywords="['theme']" icon="ti ti-palette">
|
||||
<div class="_gaps_m">
|
||||
<div
|
||||
class="_gaps_m"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@drop.prevent.stop="onDrop"
|
||||
>
|
||||
<div v-adaptive-border class="rfqxtzch _panel">
|
||||
<div class="toggle">
|
||||
<div class="toggleWrapper">
|
||||
|
@ -58,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:class="$style.themeRadio"
|
||||
:value="instanceLightTheme.id"
|
||||
/>
|
||||
<label :for="`themeRadio_${instanceLightTheme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(instanceLightTheme, $event)">
|
||||
<label :for="`themeRadio_${instanceLightTheme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, instanceLightTheme)" @contextmenu.prevent.stop="onThemeContextmenu(instanceLightTheme, $event)">
|
||||
<MkThemePreview :theme="instanceLightTheme" :class="$style.themeItemPreview"/>
|
||||
<div :class="$style.themeItemCaption">{{ instanceLightTheme.name }}</div>
|
||||
</label>
|
||||
|
@ -78,7 +82,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:class="$style.themeRadio"
|
||||
:value="theme.id"
|
||||
/>
|
||||
<label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)">
|
||||
<label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, theme)" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)">
|
||||
<MkThemePreview :theme="theme" :class="$style.themeItemPreview"/>
|
||||
<div :class="$style.themeItemCaption">{{ theme.name }}</div>
|
||||
</label>
|
||||
|
@ -98,7 +102,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:class="$style.themeRadio"
|
||||
:value="theme.id"
|
||||
/>
|
||||
<label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)">
|
||||
<label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, theme)" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)">
|
||||
<MkThemePreview :theme="theme" :class="$style.themeItemPreview"/>
|
||||
<div :class="$style.themeItemCaption">{{ theme.name }}</div>
|
||||
</label>
|
||||
|
@ -129,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:class="$style.themeRadio"
|
||||
:value="instanceDarkTheme.id"
|
||||
/>
|
||||
<label :for="`themeRadio_${instanceDarkTheme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(instanceDarkTheme, $event)">
|
||||
<label :for="`themeRadio_${instanceDarkTheme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, instanceDarkTheme)" @contextmenu.prevent.stop="onThemeContextmenu(instanceDarkTheme, $event)">
|
||||
<MkThemePreview :theme="instanceDarkTheme" :class="$style.themeItemPreview"/>
|
||||
<div :class="$style.themeItemCaption">{{ instanceDarkTheme.name }}</div>
|
||||
</label>
|
||||
|
@ -149,7 +153,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:class="$style.themeRadio"
|
||||
:value="theme.id"
|
||||
/>
|
||||
<label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)">
|
||||
<label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, theme)" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)">
|
||||
<MkThemePreview :theme="theme" :class="$style.themeItemPreview"/>
|
||||
<div :class="$style.themeItemCaption">{{ theme.name }}</div>
|
||||
</label>
|
||||
|
@ -169,7 +173,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:class="$style.themeRadio"
|
||||
:value="theme.id"
|
||||
/>
|
||||
<label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)">
|
||||
<label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button" draggable="true" @dragstart="onThemeDragstart($event, theme)" @contextmenu.prevent.stop="onThemeContextmenu(theme, $event)">
|
||||
<MkThemePreview :theme="theme" :class="$style.themeItemPreview"/>
|
||||
<div :class="$style.themeItemCaption">{{ theme.name }}</div>
|
||||
</label>
|
||||
|
@ -214,7 +218,7 @@ import FormLink from '@/components/form/link.vue';
|
|||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkThemePreview from '@/components/MkThemePreview.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import { getBuiltinThemesRef, getThemesRef, removeTheme } from '@/theme.js';
|
||||
import { getBuiltinThemesRef, getThemesRef, installTheme, parseThemeCode, removeTheme } from '@/theme.js';
|
||||
import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js';
|
||||
import { store } from '@/store.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
@ -223,6 +227,7 @@ import { uniqueBy } from '@/utility/array.js';
|
|||
import { definePage } from '@/page.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||
import { checkDragDataType, getDragData, getPlainDragData, setDragData, setPlainDragData } from '@/drag-and-drop.js';
|
||||
|
||||
const installedThemes = getThemesRef();
|
||||
const builtinThemes = getBuiltinThemesRef();
|
||||
|
@ -321,6 +326,38 @@ function onThemeContextmenu(theme: Theme, ev: MouseEvent) {
|
|||
}], ev);
|
||||
}
|
||||
|
||||
function onThemeDragstart(ev: DragEvent, theme: Theme) {
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
ev.dataTransfer.effectAllowed = 'copy';
|
||||
setPlainDragData(ev, JSON5.stringify(theme, null, '\t'));
|
||||
}
|
||||
|
||||
function onDragover(ev: DragEvent) {
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
if (ev.dataTransfer.types[0] === 'text/plain') {
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
} else {
|
||||
ev.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function onDrop(ev: DragEvent) {
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
const code = getPlainDragData(ev);
|
||||
if (code != null) {
|
||||
try {
|
||||
await installTheme(code);
|
||||
} catch (err) {
|
||||
// nop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => []);
|
||||
|
|
|
@ -439,6 +439,9 @@ export const PREF_DEF = definePreferences({
|
|||
defaultImageCompressionLevel: {
|
||||
default: 2 as 0 | 1 | 2 | 3,
|
||||
},
|
||||
defaultVideoCompressionLevel: {
|
||||
default: 2 as 0 | 1 | 2 | 3,
|
||||
},
|
||||
|
||||
'sound.masterVolume': {
|
||||
default: 0.5,
|
||||
|
@ -508,7 +511,7 @@ export const PREF_DEF = definePreferences({
|
|||
default: false,
|
||||
},
|
||||
'experimental.enableFolderPageView': {
|
||||
default: false,
|
||||
default: true,
|
||||
},
|
||||
'experimental.enableHapticFeedback': {
|
||||
default: false,
|
||||
|
|
|
@ -590,6 +590,10 @@ export const ROUTE_DEF = [{
|
|||
path: '/reversi/g/:gameId',
|
||||
component: page(() => import('@/pages/reversi/game.vue')),
|
||||
loginRequired: false,
|
||||
}, {
|
||||
path: '/qr',
|
||||
component: page(() => import('@/pages/qr.vue')),
|
||||
loginRequired: true,
|
||||
}, {
|
||||
path: '/debug',
|
||||
component: page(() => import('@/pages/debug.vue')),
|
||||
|
|
|
@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #header>
|
||||
<template v-if="pageMetadata">
|
||||
<i :class="pageMetadata.icon"></i>
|
||||
{{ pageMetadata.title }}
|
||||
<Mfm :text="pageMetadata.title" :plain="true"/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -215,16 +215,31 @@ 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',
|
||||
text: i18n.ts.searchThisUsersNotes,
|
||||
action: () => {
|
||||
router.push('/search', {
|
||||
query: {
|
||||
const query = {
|
||||
username: user.username,
|
||||
host: user.host ?? undefined,
|
||||
},
|
||||
} as { username: string, host?: string };
|
||||
|
||||
if (user.host !== null) {
|
||||
query.host = user.host;
|
||||
}
|
||||
|
||||
router.push('/search', {
|
||||
query
|
||||
});
|
||||
},
|
||||
});
|
||||
|
@ -366,8 +381,8 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
|
|||
//}
|
||||
|
||||
menuItems.push({ type: 'divider' }, {
|
||||
icon: 'ti ti-mail',
|
||||
text: i18n.ts.sendMessage,
|
||||
icon: 'ti ti-pencil-heart',
|
||||
text: i18n.ts.createUserSpecifiedNote,
|
||||
action: () => {
|
||||
const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`;
|
||||
os.post({ specified: user, initialText: `${canonical} ` });
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -18,6 +18,9 @@ import { FX_stripe } from './fxs/stripe.js';
|
|||
import { FX_threshold } from './fxs/threshold.js';
|
||||
import { FX_zoomLines } from './fxs/zoomLines.js';
|
||||
import { FX_blockNoise } from './fxs/blockNoise.js';
|
||||
import { FX_fill } from './fxs/fill.js';
|
||||
import { FX_blur } from './fxs/blur.js';
|
||||
import { FX_pixelate } from './fxs/pixelate.js';
|
||||
import type { ImageEffectorFx } from './ImageEffector.js';
|
||||
|
||||
export const FXS = [
|
||||
|
@ -36,4 +39,7 @@ export const FXS = [
|
|||
FX_chromaticAberration,
|
||||
FX_tearing,
|
||||
FX_blockNoise,
|
||||
FX_fill,
|
||||
FX_blur,
|
||||
FX_pixelate,
|
||||
] as const satisfies ImageEffectorFx<string, any>[];
|
||||
|
|
|
@ -0,0 +1,157 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const shader = `#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
const float PI = 3.141592653589793;
|
||||
const float TWO_PI = 6.283185307179586;
|
||||
const float HALF_PI = 1.5707963267948966;
|
||||
|
||||
in vec2 in_uv;
|
||||
uniform sampler2D in_texture;
|
||||
uniform vec2 in_resolution;
|
||||
uniform vec2 u_offset;
|
||||
uniform vec2 u_scale;
|
||||
uniform bool u_ellipse;
|
||||
uniform float u_angle;
|
||||
uniform float u_radius;
|
||||
uniform int u_samples;
|
||||
out vec4 out_color;
|
||||
|
||||
void main() {
|
||||
float angle = -(u_angle * PI);
|
||||
vec2 centeredUv = in_uv - vec2(0.5, 0.5) - u_offset;
|
||||
vec2 rotatedUV = vec2(
|
||||
centeredUv.x * cos(angle) - centeredUv.y * sin(angle),
|
||||
centeredUv.x * sin(angle) + centeredUv.y * cos(angle)
|
||||
) + u_offset;
|
||||
|
||||
bool isInside = false;
|
||||
if (u_ellipse) {
|
||||
vec2 norm = (rotatedUV - u_offset) / u_scale;
|
||||
isInside = dot(norm, norm) <= 1.0;
|
||||
} else {
|
||||
isInside = rotatedUV.x > u_offset.x - u_scale.x && rotatedUV.x < u_offset.x + u_scale.x && rotatedUV.y > u_offset.y - u_scale.y && rotatedUV.y < u_offset.y + u_scale.y;
|
||||
}
|
||||
|
||||
if (!isInside) {
|
||||
out_color = texture(in_texture, in_uv);
|
||||
return;
|
||||
}
|
||||
|
||||
vec4 result = vec4(0.0);
|
||||
float totalSamples = 0.0;
|
||||
|
||||
// Make blur radius resolution-independent by using a percentage of image size
|
||||
// This ensures consistent visual blur regardless of image resolution
|
||||
float referenceSize = min(in_resolution.x, in_resolution.y);
|
||||
float normalizedRadius = u_radius / 100.0; // Convert radius to percentage (0-15 -> 0-0.15)
|
||||
vec2 blurOffset = vec2(normalizedRadius) / in_resolution * referenceSize;
|
||||
|
||||
// Calculate how many samples to take in each direction
|
||||
// This determines the grid density, not the blur extent
|
||||
int sampleRadius = int(sqrt(float(u_samples)) / 2.0);
|
||||
|
||||
// Sample in a grid pattern within the specified radius
|
||||
for (int x = -sampleRadius; x <= sampleRadius; x++) {
|
||||
for (int y = -sampleRadius; y <= sampleRadius; y++) {
|
||||
// Normalize the grid position to [-1, 1] range
|
||||
float normalizedX = float(x) / float(sampleRadius);
|
||||
float normalizedY = float(y) / float(sampleRadius);
|
||||
|
||||
// Scale by radius to get the actual sampling offset
|
||||
vec2 offset = vec2(normalizedX, normalizedY) * blurOffset;
|
||||
vec2 sampleUV = in_uv + offset;
|
||||
|
||||
// Only sample if within texture bounds
|
||||
if (sampleUV.x >= 0.0 && sampleUV.x <= 1.0 && sampleUV.y >= 0.0 && sampleUV.y <= 1.0) {
|
||||
result += texture(in_texture, sampleUV);
|
||||
totalSamples += 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out_color = totalSamples > 0.0 ? result / totalSamples : texture(in_texture, in_uv);
|
||||
}
|
||||
`;
|
||||
|
||||
export const FX_blur = defineImageEffectorFx({
|
||||
id: 'blur',
|
||||
name: i18n.ts._imageEffector._fxs.blur,
|
||||
shader,
|
||||
uniforms: ['offset', 'scale', 'ellipse', 'angle', 'radius', 'samples'] as const,
|
||||
params: {
|
||||
offsetX: {
|
||||
label: i18n.ts._imageEffector._fxProps.offset + ' X',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
min: -1.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
offsetY: {
|
||||
label: i18n.ts._imageEffector._fxProps.offset + ' Y',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
min: -1.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
scaleX: {
|
||||
label: i18n.ts._imageEffector._fxProps.scale + ' W',
|
||||
type: 'number',
|
||||
default: 0.5,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
scaleY: {
|
||||
label: i18n.ts._imageEffector._fxProps.scale + ' H',
|
||||
type: 'number',
|
||||
default: 0.5,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
ellipse: {
|
||||
label: i18n.ts._imageEffector._fxProps.circle,
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
angle: {
|
||||
label: i18n.ts._imageEffector._fxProps.angle,
|
||||
type: 'number',
|
||||
default: 0,
|
||||
min: -1.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 90) + '°',
|
||||
},
|
||||
radius: {
|
||||
label: i18n.ts._imageEffector._fxProps.strength,
|
||||
type: 'number',
|
||||
default: 3.0,
|
||||
min: 0.0,
|
||||
max: 10.0,
|
||||
step: 0.5,
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2);
|
||||
gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2);
|
||||
gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0);
|
||||
gl.uniform1f(u.angle, params.angle / 2);
|
||||
gl.uniform1f(u.radius, params.radius);
|
||||
gl.uniform1i(u.samples, 256);
|
||||
},
|
||||
});
|
|
@ -0,0 +1,135 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const shader = `#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
const float PI = 3.141592653589793;
|
||||
const float TWO_PI = 6.283185307179586;
|
||||
const float HALF_PI = 1.5707963267948966;
|
||||
|
||||
in vec2 in_uv;
|
||||
uniform sampler2D in_texture;
|
||||
uniform vec2 in_resolution;
|
||||
uniform vec2 u_offset;
|
||||
uniform vec2 u_scale;
|
||||
uniform bool u_ellipse;
|
||||
uniform float u_angle;
|
||||
uniform vec3 u_color;
|
||||
uniform float u_opacity;
|
||||
out vec4 out_color;
|
||||
|
||||
void main() {
|
||||
vec4 in_color = texture(in_texture, in_uv);
|
||||
//float x_ratio = max(in_resolution.x / in_resolution.y, 1.0);
|
||||
//float y_ratio = max(in_resolution.y / in_resolution.x, 1.0);
|
||||
|
||||
float angle = -(u_angle * PI);
|
||||
vec2 centeredUv = in_uv - vec2(0.5, 0.5) - u_offset;
|
||||
vec2 rotatedUV = vec2(
|
||||
centeredUv.x * cos(angle) - centeredUv.y * sin(angle),
|
||||
centeredUv.x * sin(angle) + centeredUv.y * cos(angle)
|
||||
) + u_offset;
|
||||
|
||||
bool isInside = false;
|
||||
if (u_ellipse) {
|
||||
vec2 norm = (rotatedUV - u_offset) / u_scale;
|
||||
isInside = dot(norm, norm) <= 1.0;
|
||||
} else {
|
||||
isInside = rotatedUV.x > u_offset.x - u_scale.x && rotatedUV.x < u_offset.x + u_scale.x && rotatedUV.y > u_offset.y - u_scale.y && rotatedUV.y < u_offset.y + u_scale.y;
|
||||
}
|
||||
|
||||
out_color = isInside ? vec4(
|
||||
mix(in_color.r, u_color.r, u_opacity),
|
||||
mix(in_color.g, u_color.g, u_opacity),
|
||||
mix(in_color.b, u_color.b, u_opacity),
|
||||
in_color.a
|
||||
) : in_color;
|
||||
}
|
||||
`;
|
||||
|
||||
export const FX_fill = defineImageEffectorFx({
|
||||
id: 'fill',
|
||||
name: i18n.ts._imageEffector._fxs.fill,
|
||||
shader,
|
||||
uniforms: ['offset', 'scale', 'ellipse', 'angle', 'color', 'opacity'] as const,
|
||||
params: {
|
||||
offsetX: {
|
||||
label: i18n.ts._imageEffector._fxProps.offset + ' X',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
min: -1.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
offsetY: {
|
||||
label: i18n.ts._imageEffector._fxProps.offset + ' Y',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
min: -1.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
scaleX: {
|
||||
label: i18n.ts._imageEffector._fxProps.scale + ' W',
|
||||
type: 'number',
|
||||
default: 0.5,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
scaleY: {
|
||||
label: i18n.ts._imageEffector._fxProps.scale + ' H',
|
||||
type: 'number',
|
||||
default: 0.5,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
ellipse: {
|
||||
label: i18n.ts._imageEffector._fxProps.circle,
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
angle: {
|
||||
label: i18n.ts._imageEffector._fxProps.angle,
|
||||
type: 'number',
|
||||
default: 0,
|
||||
min: -1.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 90) + '°',
|
||||
},
|
||||
color: {
|
||||
label: i18n.ts._imageEffector._fxProps.color,
|
||||
type: 'color',
|
||||
default: [1, 1, 1],
|
||||
},
|
||||
opacity: {
|
||||
label: i18n.ts._imageEffector._fxProps.opacity,
|
||||
type: 'number',
|
||||
default: 1.0,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2);
|
||||
gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2);
|
||||
gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0);
|
||||
gl.uniform1f(u.angle, params.angle / 2);
|
||||
gl.uniform3f(u.color, params.color[0], params.color[1], params.color[2]);
|
||||
gl.uniform1f(u.opacity, params.opacity);
|
||||
},
|
||||
});
|
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { defineImageEffectorFx } from '../ImageEffector.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const shader = `#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
const float PI = 3.141592653589793;
|
||||
const float TWO_PI = 6.283185307179586;
|
||||
const float HALF_PI = 1.5707963267948966;
|
||||
|
||||
in vec2 in_uv;
|
||||
uniform sampler2D in_texture;
|
||||
uniform vec2 in_resolution;
|
||||
uniform vec2 u_offset;
|
||||
uniform vec2 u_scale;
|
||||
uniform bool u_ellipse;
|
||||
uniform float u_angle;
|
||||
uniform int u_samples;
|
||||
uniform float u_strength;
|
||||
out vec4 out_color;
|
||||
|
||||
// TODO: pixelateの中心を画像中心ではなく範囲の中心にする
|
||||
// TODO: 画像のアスペクト比に関わらず各画素は正方形にする
|
||||
|
||||
void main() {
|
||||
if (u_strength <= 0.0) {
|
||||
out_color = texture(in_texture, in_uv);
|
||||
return;
|
||||
}
|
||||
|
||||
float angle = -(u_angle * PI);
|
||||
vec2 centeredUv = in_uv - vec2(0.5, 0.5) - u_offset;
|
||||
vec2 rotatedUV = vec2(
|
||||
centeredUv.x * cos(angle) - centeredUv.y * sin(angle),
|
||||
centeredUv.x * sin(angle) + centeredUv.y * cos(angle)
|
||||
) + u_offset;
|
||||
|
||||
bool isInside = false;
|
||||
if (u_ellipse) {
|
||||
vec2 norm = (rotatedUV - u_offset) / u_scale;
|
||||
isInside = dot(norm, norm) <= 1.0;
|
||||
} else {
|
||||
isInside = rotatedUV.x > u_offset.x - u_scale.x && rotatedUV.x < u_offset.x + u_scale.x && rotatedUV.y > u_offset.y - u_scale.y && rotatedUV.y < u_offset.y + u_scale.y;
|
||||
}
|
||||
|
||||
if (!isInside) {
|
||||
out_color = texture(in_texture, in_uv);
|
||||
return;
|
||||
}
|
||||
|
||||
float dx = u_strength / 1.0;
|
||||
float dy = u_strength / 1.0;
|
||||
vec2 new_uv = vec2(
|
||||
(dx * (floor((in_uv.x - 0.5 - (dx / 2.0)) / dx) + 0.5)),
|
||||
(dy * (floor((in_uv.y - 0.5 - (dy / 2.0)) / dy) + 0.5))
|
||||
) + vec2(0.5 + (dx / 2.0), 0.5 + (dy / 2.0));
|
||||
|
||||
vec4 result = vec4(0.0);
|
||||
float totalSamples = 0.0;
|
||||
|
||||
// TODO: より多くのサンプリング
|
||||
result += texture(in_texture, new_uv);
|
||||
totalSamples += 1.0;
|
||||
|
||||
out_color = totalSamples > 0.0 ? result / totalSamples : texture(in_texture, in_uv);
|
||||
}
|
||||
`;
|
||||
|
||||
export const FX_pixelate = defineImageEffectorFx({
|
||||
id: 'pixelate',
|
||||
name: i18n.ts._imageEffector._fxs.pixelate,
|
||||
shader,
|
||||
uniforms: ['offset', 'scale', 'ellipse', 'angle', 'strength', 'samples'] as const,
|
||||
params: {
|
||||
offsetX: {
|
||||
label: i18n.ts._imageEffector._fxProps.offset + ' X',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
min: -1.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
offsetY: {
|
||||
label: i18n.ts._imageEffector._fxProps.offset + ' Y',
|
||||
type: 'number',
|
||||
default: 0.0,
|
||||
min: -1.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
scaleX: {
|
||||
label: i18n.ts._imageEffector._fxProps.scale + ' W',
|
||||
type: 'number',
|
||||
default: 0.5,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
scaleY: {
|
||||
label: i18n.ts._imageEffector._fxProps.scale + ' H',
|
||||
type: 'number',
|
||||
default: 0.5,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 100) + '%',
|
||||
},
|
||||
ellipse: {
|
||||
label: i18n.ts._imageEffector._fxProps.circle,
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
angle: {
|
||||
label: i18n.ts._imageEffector._fxProps.angle,
|
||||
type: 'number',
|
||||
default: 0,
|
||||
min: -1.0,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
toViewValue: v => Math.round(v * 90) + '°',
|
||||
},
|
||||
strength: {
|
||||
label: i18n.ts._imageEffector._fxProps.strength,
|
||||
type: 'number',
|
||||
default: 0.2,
|
||||
min: 0.0,
|
||||
max: 0.5,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
main: ({ gl, u, params }) => {
|
||||
gl.uniform2f(u.offset, params.offsetX / 2, params.offsetY / 2);
|
||||
gl.uniform2f(u.scale, params.scaleX / 2, params.scaleY / 2);
|
||||
gl.uniform1i(u.ellipse, params.ellipse ? 1 : 0);
|
||||
gl.uniform1f(u.angle, params.angle / 2);
|
||||
gl.uniform1f(u.strength, params.strength * params.strength);
|
||||
gl.uniform1i(u.samples, 256);
|
||||
},
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue