Merge remote-tracking branch 'misskey-original/develop' into develop

# Conflicts:
#	package.json
#	packages/frontend/src/components/MkPostForm.vue
#	packages/frontend/src/navbar.ts
This commit is contained in:
mattyatea 2023-12-02 00:47:40 +09:00
commit 1802c5da5f
59 changed files with 1606 additions and 951 deletions

View File

@ -20,7 +20,7 @@ jobs:
sudo dpkg -i dockle.deb sudo dpkg -i dockle.deb
- run: | - run: |
cp .config/docker_example.env .config/docker.env cp .config/docker_example.env .config/docker.env
cp ./docker-compose.yml.example ./docker-compose.yml cp ./docker-compose_example.yml ./docker-compose.yml
- run: | - run: |
docker compose up -d web docker compose up -d web
docker tag "$(docker compose images web | awk 'OFS=":" {print $4}' | tail -n +2)" misskey-web:latest docker tag "$(docker compose images web | awk 'OFS=":" {print $4}' | tail -n +2)" misskey-web:latest

View File

@ -22,16 +22,13 @@ jobs:
api-json-name: [api-base.json, api-head.json] api-json-name: [api-base.json, api-head.json]
include: include:
- api-json-name: api-base.json - api-json-name: api-base.json
repo-name: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.base_ref }} ref: ${{ github.base_ref }}
- api-json-name: api-head.json - api-json-name: api-head.json
repo-name: ${{ github.event.pull_request.head.repo.full_name }} ref: refs/pull/${{ github.event.number }}/merge
ref: ${{ github.head_ref }}
steps: steps:
- uses: actions/checkout@v4.1.1 - uses: actions/checkout@v4.1.1
with: with:
repository: ${{ matrix.repo-name }}
ref: ${{ matrix.ref }} ref: ${{ matrix.ref }}
submodules: true submodules: true
- name: Install pnpm - name: Install pnpm

View File

@ -21,14 +21,23 @@
- Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正 - Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正
### Client ### Client
- Feat: 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加
- Enhance: 絵文字のオートコンプリート機能強化 #12364 - Enhance: 絵文字のオートコンプリート機能強化 #12364
- Enhance: ユーザーのRawデータを表示するページが復活 - Enhance: ユーザーのRawデータを表示するページが復活
- Enhance: リアクション選択時に音を鳴らせるように - Enhance: リアクション選択時に音を鳴らせるように
- Enhance: サウンドにドライブのファイルを使用できるように
- Enhance: ナビゲーションバーに項目「キャッシュを削除」を追加
- Enhance: Shareページで投稿を完了すると、親ウィンドウ親フレームにpostMessageするように
- Enhance: チャンネル、クリップ、ページ、Play、ギャラリーにURLのコピーボタンを設置 #11305
- Enhance: ノートプレビューに「内容を隠す」が反映されるように
- fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正 - fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正
- Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367 - Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367
- Enhance: 絵文字の詳細ページに記載される情報を追加
- Fix: コードエディタが正しく表示されない問題を修正 - Fix: コードエディタが正しく表示されない問題を修正
- Fix: プロフィールの「ファイル」にセンシティブな画像がある際のデザインを修正 - Fix: プロフィールの「ファイル」にセンシティブな画像がある際のデザインを修正
- Fix: 一度に大量の通知が入った際に通知音が音割れする問題を修正 - Fix: 一度に大量の通知が入った際に通知音が音割れする問題を修正
- Fix: 共有機能をサポートしていないブラウザの場合は共有ボタンを非表示にする #11305
- Fix: 通知のグルーピング設定を変更してもリロードされるまで表示が変わらない問題を修正 #12470
### Server ### Server
- Enhance: MFM `$[ruby ]` が他ソフトウェアと連合されるように - Enhance: MFM `$[ruby ]` が他ソフトウェアと連合されるように
@ -41,6 +50,9 @@
## 2023.11.1 ## 2023.11.1
### Note
- 悪意のある第三者がリモートユーザーになりすました任意のアクティビティを受け取れてしまう問題を修正しました。詳しくは[GitHub security advisory](https://github.com/misskey-dev/misskey/security/advisories/GHSA-3f39-6537-3cgc)をご覧ください。
### General ### General
- Feat: 管理者がコントロールパネルからメールアドレスの照会を行えるようになりました - Feat: 管理者がコントロールパネルからメールアドレスの照会を行えるようになりました
- Enhance: ローカリゼーションの更新 - Enhance: ローカリゼーションの更新

View File

@ -67,8 +67,8 @@ RUN apt-get update \
&& corepack enable \ && corepack enable \
&& groupadd -g "${GID}" misskey \ && groupadd -g "${GID}" misskey \
&& useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey \ && useradd -l -u "${UID}" -g "${GID}" -m -d /misskey misskey \
&& find / -type d -path /proc -prune -o -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \ && find / -type d -path /sys -prune -o -type d -path /proc -prune -o -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \
&& find / -type d -path /proc -prune -o -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; \ && find / -type d -path /sys -prune -o -type d -path /proc -prune -o -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists && rm -rf /var/lib/apt/lists

View File

@ -56,6 +56,18 @@ export default function generateDTS() {
ts.NodeFlags.Const | ts.NodeFlags.Ambient | ts.NodeFlags.ContextFlags, ts.NodeFlags.Const | ts.NodeFlags.Ambient | ts.NodeFlags.ContextFlags,
), ),
), ),
ts.factory.createFunctionDeclaration(
[ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
undefined,
ts.factory.createIdentifier('build'),
undefined,
[],
ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('Locale'),
undefined,
),
undefined,
),
ts.factory.createExportDefault(ts.factory.createIdentifier('locales')), ts.factory.createExportDefault(ts.factory.createIdentifier('locales')),
]; ];
const printed = ts.createPrinter({ const printed = ts.createPrinter({

12
locales/index.d.ts vendored
View File

@ -1063,6 +1063,8 @@ export interface Locale {
"sensitiveWords": string; "sensitiveWords": string;
"sensitiveWordsDescription": string; "sensitiveWordsDescription": string;
"sensitiveWordsDescription2": string; "sensitiveWordsDescription2": string;
"hiddenTags": string;
"hiddenTagsDescription": string;
"notesSearchNotAvailable": string; "notesSearchNotAvailable": string;
"license": string; "license": string;
"draft": string; "draft": string;
@ -1988,6 +1990,14 @@ export interface Locale {
"channel": string; "channel": string;
"reaction": string; "reaction": string;
}; };
"_soundSettings": {
"driveFile": string;
"driveFileWarn": string;
"driveFileTypeWarn": string;
"driveFileTypeWarnDescription": string;
"driveFileDurationWarn": string;
"driveFileDurationWarnDescription": string;
};
"_ago": { "_ago": {
"future": string; "future": string;
"justNow": string; "justNow": string;
@ -2155,6 +2165,7 @@ export interface Locale {
"chooseList": string; "chooseList": string;
}; };
"clicker": string; "clicker": string;
"birthdayFollowings": string;
}; };
"_cw": { "_cw": {
"hide": string; "hide": string;
@ -2551,4 +2562,5 @@ export interface Locale {
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;
}; };
export function build(): Locale;
export default locales; export default locales;

View File

@ -51,10 +51,11 @@ const primaries = {
// 何故か文字列にバックスペース文字が混入することがあり、YAMLが壊れるので取り除く // 何故か文字列にバックスペース文字が混入することがあり、YAMLが壊れるので取り除く
const clean = (text) => text.replace(new RegExp(String.fromCodePoint(0x08), 'g'), ''); const clean = (text) => text.replace(new RegExp(String.fromCodePoint(0x08), 'g'), '');
const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(new URL(`${c}.yml`, import.meta.url), 'utf-8'))) || {}, a), {}); export function build() {
const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(new URL(`${c}.yml`, import.meta.url), 'utf-8'))) || {}, a), {});
// 空文字列が入ることがあり、フォールバックが動作しなくなるのでプロパティごと消す // 空文字列が入ることがあり、フォールバックが動作しなくなるのでプロパティごと消す
const removeEmpty = (obj) => { const removeEmpty = (obj) => {
for (const [k, v] of Object.entries(obj)) { for (const [k, v] of Object.entries(obj)) {
if (v === '') { if (v === '') {
delete obj[k]; delete obj[k];
@ -63,11 +64,11 @@ const removeEmpty = (obj) => {
} }
} }
return obj; return obj;
}; };
removeEmpty(locales); removeEmpty(locales);
export default Object.entries(locales) return Object.entries(locales)
.reduce((a, [k ,v]) => (a[k] = (() => { .reduce((a, [k, v]) => (a[k] = (() => {
const [lang] = k.split('-'); const [lang] = k.split('-');
switch (k) { switch (k) {
case 'ja-JP': return v; case 'ja-JP': return v;
@ -81,3 +82,6 @@ export default Object.entries(locales)
); );
} }
})(), a), {}); })(), a), {});
}
export default build();

View File

@ -1060,6 +1060,8 @@ resetPasswordConfirm: "パスワードリセットしますか?"
sensitiveWords: "センシティブワード" sensitiveWords: "センシティブワード"
sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。" sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。"
sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。" sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。"
hiddenTags: "非表示ハッシュタグ"
hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。"
notesSearchNotAvailable: "ノート検索は利用できません。" notesSearchNotAvailable: "ノート検索は利用できません。"
license: "ライセンス" license: "ライセンス"
draft: "ドラフト" draft: "ドラフト"
@ -1893,6 +1895,14 @@ _sfx:
channel: "チャンネル通知" channel: "チャンネル通知"
reaction: "リアクション選択時" reaction: "リアクション選択時"
_soundSettings:
driveFile: "ドライブの音声を使用"
driveFileWarn: "ドライブのファイルを選択してください"
driveFileTypeWarn: "このファイルは対応していません"
driveFileTypeWarnDescription: "音声ファイルを選択してください"
driveFileDurationWarn: "音声が長すぎます"
driveFileDurationWarnDescription: "長い音声を使用するとMisskeyの使用に支障をきたす可能性があります。それでも続行しますか"
_ago: _ago:
future: "未来" future: "未来"
justNow: "たった今" justNow: "たった今"
@ -2059,6 +2069,7 @@ _widgets:
_userList: _userList:
chooseList: "リストを選択" chooseList: "リストを選択"
clicker: "クリッカー" clicker: "クリッカー"
birthdayFollowings: "今日誕生日のユーザー"
_cw: _cw:
hide: "隠す" hide: "隠す"

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2023.11.1-PrisMisskey.2", "version": "2023.12.0-beta.1-PrisMisskey.2",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
@ -52,11 +52,11 @@
"typescript": "5.3.2" "typescript": "5.3.2"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "6.11.0", "@typescript-eslint/eslint-plugin": "6.12.0",
"@typescript-eslint/parser": "6.11.0", "@typescript-eslint/parser": "6.12.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "13.5.1", "cypress": "13.6.0",
"eslint": "8.53.0", "eslint": "8.54.0",
"start-server-and-test": "2.0.3" "start-server-and-test": "2.0.3"
}, },
"optionalDependencies": { "optionalDependencies": {

View File

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddBdayIndex1700902349231 {
name = 'AddBdayIndex1700902349231'
async up(queryRunner) {
await queryRunner.query(`CREATE INDEX "IDX_de22cd2b445eee31ae51cdbe99" ON "user_profile" (SUBSTR("birthday", 6, 5))`);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_de22cd2b445eee31ae51cdbe99"`);
}
}

View File

@ -60,9 +60,9 @@
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.412.0", "@aws-sdk/client-s3": "3.412.0",
"@aws-sdk/lib-storage": "3.412.0", "@aws-sdk/lib-storage": "3.412.0",
"@bull-board/api": "5.9.1", "@bull-board/api": "5.9.2",
"@bull-board/fastify": "5.9.1", "@bull-board/fastify": "5.9.2",
"@bull-board/ui": "5.9.1", "@bull-board/ui": "5.9.2",
"@discordapp/twemoji": "14.1.2", "@discordapp/twemoji": "14.1.2",
"@fastify/accepts": "4.2.0", "@fastify/accepts": "4.2.0",
"@fastify/cookie": "9.2.0", "@fastify/cookie": "9.2.0",
@ -72,15 +72,15 @@
"@fastify/multipart": "8.0.0", "@fastify/multipart": "8.0.0",
"@fastify/static": "6.12.0", "@fastify/static": "6.12.0",
"@fastify/view": "8.2.0", "@fastify/view": "8.2.0",
"@nestjs/common": "10.2.8", "@nestjs/common": "10.2.10",
"@nestjs/core": "10.2.8", "@nestjs/core": "10.2.10",
"@nestjs/testing": "10.2.8", "@nestjs/testing": "10.2.10",
"@peertube/http-signature": "1.7.0", "@peertube/http-signature": "1.7.0",
"@simplewebauthn/server": "8.3.5", "@simplewebauthn/server": "8.3.5",
"@sinonjs/fake-timers": "11.2.2", "@sinonjs/fake-timers": "11.2.2",
"@smithy/node-http-handler": "2.1.5", "@smithy/node-http-handler": "2.1.10",
"@swc/cli": "0.1.63", "@swc/cli": "0.1.63",
"@swc/core": "1.3.96", "@swc/core": "1.3.99",
"accepts": "1.3.8", "accepts": "1.3.8",
"ajv": "8.12.0", "ajv": "8.12.0",
"archiver": "6.0.1", "archiver": "6.0.1",
@ -88,7 +88,7 @@
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"body-parser": "1.20.2", "body-parser": "1.20.2",
"bullmq": "4.13.3", "bullmq": "4.14.2",
"cacheable-lookup": "7.0.0", "cacheable-lookup": "7.0.0",
"cbor": "9.0.1", "cbor": "9.0.1",
"chalk": "5.3.0", "chalk": "5.3.0",
@ -114,11 +114,11 @@
"ipaddr.js": "2.1.0", "ipaddr.js": "2.1.0",
"is-svg": "5.0.0", "is-svg": "5.0.0",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"jsdom": "22.1.0", "jsdom": "23.0.0",
"json5": "2.2.3", "json5": "2.2.3",
"jsonld": "8.3.1", "jsonld": "8.3.1",
"jsrsasign": "10.8.6", "jsrsasign": "10.9.0",
"meilisearch": "0.35.0", "meilisearch": "0.36.0",
"mfm-js": "0.23.3", "mfm-js": "0.23.3",
"microformats-parser": "1.5.2", "microformats-parser": "1.5.2",
"mime-types": "2.1.35", "mime-types": "2.1.35",
@ -145,7 +145,7 @@
"qrcode": "1.5.3", "qrcode": "1.5.3",
"random-seed": "0.3.0", "random-seed": "0.3.0",
"ratelimiter": "3.4.1", "ratelimiter": "3.4.1",
"re2": "1.20.8", "re2": "1.20.9",
"redis-lock": "0.1.4", "redis-lock": "0.1.4",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"rename": "1.0.4", "rename": "1.0.4",
@ -159,7 +159,7 @@
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly", "summaly": "github:misskey-dev/summaly",
"systeminformation": "5.21.17", "systeminformation": "5.21.18",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tmp": "0.2.1", "tmp": "0.2.1",
"tsc-alias": "1.8.8", "tsc-alias": "1.8.8",
@ -178,7 +178,7 @@
"@simplewebauthn/typescript-types": "8.3.4", "@simplewebauthn/typescript-types": "8.3.4",
"@swc/jest": "0.2.29", "@swc/jest": "0.2.29",
"@types/accepts": "1.3.7", "@types/accepts": "1.3.7",
"@types/archiver": "6.0.1", "@types/archiver": "6.0.2",
"@types/bcryptjs": "2.4.6", "@types/bcryptjs": "2.4.6",
"@types/body-parser": "1.19.5", "@types/body-parser": "1.19.5",
"@types/cbor": "6.0.0", "@types/cbor": "6.0.0",
@ -186,28 +186,28 @@
"@types/content-disposition": "0.5.8", "@types/content-disposition": "0.5.8",
"@types/fluent-ffmpeg": "2.1.24", "@types/fluent-ffmpeg": "2.1.24",
"@types/http-link-header": "1.0.5", "@types/http-link-header": "1.0.5",
"@types/jest": "29.5.8", "@types/jest": "29.5.10",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/jsdom": "21.1.5", "@types/jsdom": "21.1.6",
"@types/jsonld": "1.5.12", "@types/jsonld": "1.5.13",
"@types/jsrsasign": "10.5.12", "@types/jsrsasign": "10.5.12",
"@types/mime-types": "2.1.4", "@types/mime-types": "2.1.4",
"@types/ms": "0.7.34", "@types/ms": "0.7.34",
"@types/node": "20.9.1", "@types/node": "20.10.0",
"@types/node-fetch": "3.0.3", "@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.14", "@types/nodemailer": "6.4.14",
"@types/oauth": "0.9.4", "@types/oauth": "0.9.4",
"@types/oauth2orize": "1.11.3", "@types/oauth2orize": "1.11.3",
"@types/oauth2orize-pkce": "0.1.2", "@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.10.9", "@types/pg": "8.10.9",
"@types/pug": "2.0.9", "@types/pug": "2.0.10",
"@types/punycode": "2.1.2", "@types/punycode": "2.1.3",
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
"@types/random-seed": "0.3.5", "@types/random-seed": "0.3.5",
"@types/ratelimiter": "3.4.6", "@types/ratelimiter": "3.4.6",
"@types/rename": "1.0.7", "@types/rename": "1.0.7",
"@types/sanitize-html": "2.9.4", "@types/sanitize-html": "2.9.5",
"@types/semver": "7.5.5", "@types/semver": "7.5.6",
"@types/sharp": "0.32.0", "@types/sharp": "0.32.0",
"@types/simple-oauth2": "5.0.7", "@types/simple-oauth2": "5.0.7",
"@types/sinonjs__fake-timers": "8.1.5", "@types/sinonjs__fake-timers": "8.1.5",
@ -215,12 +215,12 @@
"@types/tmp": "0.2.6", "@types/tmp": "0.2.6",
"@types/vary": "1.1.3", "@types/vary": "1.1.3",
"@types/web-push": "3.6.3", "@types/web-push": "3.6.3",
"@types/ws": "8.5.9", "@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "6.11.0", "@typescript-eslint/eslint-plugin": "6.12.0",
"@typescript-eslint/parser": "6.11.0", "@typescript-eslint/parser": "6.12.0",
"aws-sdk-client-mock": "3.0.0", "aws-sdk-client-mock": "3.0.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint": "8.53.0", "eslint": "8.54.0",
"eslint-plugin-import": "2.29.0", "eslint-plugin-import": "2.29.0",
"execa": "8.0.1", "execa": "8.0.1",
"jest": "29.7.0", "jest": "29.7.0",

View File

@ -29,6 +29,7 @@ export class MiUserProfile {
}) })
public location: string | null; public location: string | null;
@Index()
@Column('char', { @Column('char', {
length: 10, nullable: true, length: 10, nullable: true,
comment: 'The birthday (YYYY-MM-DD) of the User.', comment: 'The birthday (YYYY-MM-DD) of the User.',

View File

@ -191,6 +191,10 @@ export const packedNoteSchema = {
optional: false, nullable: false, optional: false, nullable: false,
}, },
}, },
clippedCount: {
type: 'number',
optional: true, nullable: false,
},
myReaction: { myReaction: {
type: 'object', type: 'object',

View File

@ -33,13 +33,7 @@ export const meta = {
items: { items: {
type: 'object', type: 'object',
optional: false, nullable: false, optional: false, nullable: false,
properties: { ref: 'InviteCode',
code: {
type: 'string',
optional: false, nullable: false,
example: 'GR6S02ERUA5VR',
},
},
}, },
}, },
} as const; } as const;

View File

@ -21,6 +21,7 @@ export const meta = {
items: { items: {
type: 'object', type: 'object',
optional: false, nullable: false, optional: false, nullable: false,
ref: 'InviteCode',
}, },
}, },
} as const; } as const;

View File

@ -31,13 +31,7 @@ export const meta = {
res: { res: {
type: 'object', type: 'object',
optional: false, nullable: false, optional: false, nullable: false,
properties: { ref: 'InviteCode',
code: {
type: 'string',
optional: false, nullable: false,
example: 'GR6S02ERUA5VR',
},
},
}, },
} as const; } as const;

View File

@ -9,7 +9,6 @@ import type { RegistrationTicketsRepository } from '@/models/_.js';
import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js'; import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
export const meta = { export const meta = {
tags: ['meta'], tags: ['meta'],
@ -23,6 +22,7 @@ export const meta = {
items: { items: {
type: 'object', type: 'object',
optional: false, nullable: false, optional: false, nullable: false,
ref: 'InviteCode',
}, },
}, },
} as const; } as const;

View File

@ -42,6 +42,12 @@ export const meta = {
code: 'FORBIDDEN', code: 'FORBIDDEN',
id: 'f6cdb0df-c19f-ec5c-7dbb-0ba84a1f92ba', id: 'f6cdb0df-c19f-ec5c-7dbb-0ba84a1f92ba',
}, },
birthdayInvalid: {
message: 'Birthday date format is invalid.',
code: 'BIRTHDAY_DATE_FORMAT_INVALID',
id: 'a2b007b9-4782-4eba-abd3-93b05ed4130d',
},
}, },
} as const; } as const;
@ -59,6 +65,8 @@ export const paramDef = {
nullable: true, nullable: true,
description: 'The local host is represented with `null`.', description: 'The local host is represented with `null`.',
}, },
birthday: { type: 'string', nullable: true },
}, },
anyOf: [ anyOf: [
{ required: ['userId'] }, { required: ['userId'] },
@ -117,6 +125,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.andWhere('following.followerId = :userId', { userId: user.id }) .andWhere('following.followerId = :userId', { userId: user.id })
.innerJoinAndSelect('following.followee', 'followee'); .innerJoinAndSelect('following.followee', 'followee');
if (ps.birthday) {
try {
const d = new Date(ps.birthday);
d.setHours(0, 0, 0, 0);
const birthday = `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile');
birthdayUserQuery.select('user_profile.userId')
.where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`);
query.andWhere(`following.followeeId IN (${ birthdayUserQuery.getQuery() })`);
} catch (err) {
throw new ApiError(meta.errors.birthdayInvalid);
}
}
const followings = await query const followings = await query
.limit(ps.limit) .limit(ps.limit)
.getMany(); .getMany();

View File

@ -9,7 +9,7 @@
"build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js", "build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
"build-storybook": "pnpm build-storybook-pre && storybook build", "build-storybook": "pnpm build-storybook-pre && storybook build",
"chromatic": "chromatic", "chromatic": "chromatic",
"test": "vitest --run", "test": "vitest --run --globals",
"test-and-coverage": "vitest --run --coverage --globals", "test-and-coverage": "vitest --run --coverage --globals",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.{ts,vue}\"", "eslint": "eslint --quiet \"src/**/*.{ts,vue}\"",
@ -18,7 +18,7 @@
"dependencies": { "dependencies": {
"@discordapp/twemoji": "14.1.2", "@discordapp/twemoji": "14.1.2",
"@github/webauthn-json": "2.1.1", "@github/webauthn-json": "2.1.1",
"@rollup/plugin-alias": "5.0.1", "@rollup/plugin-alias": "5.1.0",
"@rollup/plugin-json": "6.0.1", "@rollup/plugin-json": "6.0.1",
"@rollup/plugin-replace": "5.0.5", "@rollup/plugin-replace": "5.0.5",
"@rollup/pluginutils": "5.0.5", "@rollup/pluginutils": "5.0.5",
@ -26,7 +26,7 @@
"@tabler/icons-webfont": "2.37.0", "@tabler/icons-webfont": "2.37.0",
"@vitejs/plugin-vue": "4.5.0", "@vitejs/plugin-vue": "4.5.0",
"@vue-macros/reactivity-transform": "0.4.0", "@vue-macros/reactivity-transform": "0.4.0",
"@vue/compiler-sfc": "3.3.8", "@vue/compiler-sfc": "3.3.9",
"astring": "1.8.6", "astring": "1.8.6",
"autosize": "6.0.1", "autosize": "6.0.1",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.0.6", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.0.6",
@ -39,7 +39,7 @@
"chartjs-chart-matrix": "2.0.1", "chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1", "chartjs-plugin-zoom": "2.0.1",
"chromatic": "9.0.0", "chromatic": "9.1.0",
"compare-versions": "6.1.0", "compare-versions": "6.1.0",
"cropperjs": "2.0.0-beta.4", "cropperjs": "2.0.0-beta.4",
"date-fns": "2.30.0", "date-fns": "2.30.0",
@ -57,7 +57,7 @@
"photoswipe": "5.4.2", "photoswipe": "5.4.2",
"punycode": "2.3.1", "punycode": "2.3.1",
"querystring": "0.2.1", "querystring": "0.2.1",
"rollup": "4.4.1", "rollup": "4.6.0",
"sanitize-html": "2.11.0", "sanitize-html": "2.11.0",
"shiki": "^0.14.5", "shiki": "^0.14.5",
"sass": "1.69.5", "sass": "1.69.5",
@ -73,8 +73,8 @@
"uuid": "9.0.1", "uuid": "9.0.1",
"v-code-diff": "1.7.2", "v-code-diff": "1.7.2",
"vanilla-tilt": "1.8.1", "vanilla-tilt": "1.8.1",
"vite": "4.5.0", "vite": "5.0.2",
"vue": "3.3.8", "vue": "3.3.9",
"vuedraggable": "next" "vuedraggable": "next"
}, },
"devDependencies": { "devDependencies": {
@ -96,27 +96,27 @@
"@storybook/types": "7.5.3", "@storybook/types": "7.5.3",
"@storybook/vue3": "7.5.3", "@storybook/vue3": "7.5.3",
"@storybook/vue3-vite": "7.5.3", "@storybook/vue3-vite": "7.5.3",
"@testing-library/vue": "8.0.0", "@testing-library/vue": "8.0.1",
"@types/escape-regexp": "0.0.3", "@types/escape-regexp": "0.0.3",
"@types/estree": "1.0.5", "@types/estree": "1.0.5",
"@types/matter-js": "0.19.4", "@types/matter-js": "0.19.5",
"@types/micromatch": "4.0.5", "@types/micromatch": "4.0.6",
"@types/node": "20.9.1", "@types/node": "20.10.0",
"@types/punycode": "2.1.2", "@types/punycode": "2.1.3",
"@types/sanitize-html": "2.9.4", "@types/sanitize-html": "2.9.5",
"@types/throttle-debounce": "5.0.2", "@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6", "@types/tinycolor2": "1.4.6",
"@types/uuid": "9.0.7", "@types/uuid": "9.0.7",
"@types/websocket": "1.0.9", "@types/websocket": "1.0.10",
"@types/ws": "8.5.9", "@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "6.11.0", "@typescript-eslint/eslint-plugin": "6.12.0",
"@typescript-eslint/parser": "6.11.0", "@typescript-eslint/parser": "6.12.0",
"@vitest/coverage-v8": "0.34.6", "@vitest/coverage-v8": "0.34.6",
"@vue/runtime-core": "3.3.8", "@vue/runtime-core": "3.3.9",
"acorn": "8.11.2", "acorn": "8.11.2",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "13.5.1", "cypress": "13.6.0",
"eslint": "8.53.0", "eslint": "8.54.0",
"eslint-plugin-import": "2.29.0", "eslint-plugin-import": "2.29.0",
"eslint-plugin-vue": "9.18.1", "eslint-plugin-vue": "9.18.1",
"fast-glob": "3.3.2", "fast-glob": "3.3.2",

View File

@ -202,16 +202,24 @@ export async function common(createVue: () => App<Element>) {
} }
}, { immediate: true }); }, { immediate: true });
if (defaultStore.state.keepScreenOn) { // Keep screen on
if ('wakeLock' in navigator) { const onVisibilityChange = () => document.addEventListener('visibilitychange', () => {
navigator.wakeLock.request('screen');
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
navigator.wakeLock.request('screen'); navigator.wakeLock.request('screen');
} }
}); });
} if (defaultStore.state.keepScreenOn && 'wakeLock' in navigator) {
navigator.wakeLock.request('screen')
.then(onVisibilityChange)
.catch(() => {
// On WebKit-based browsers, user activation is required to send wake lock request
// https://webkit.org/blog/13862/the-user-activation-api/
document.addEventListener(
'click',
() => navigator.wakeLock.request('screen').then(onVisibilityChange),
{ once: true },
);
});
} }
//#region Fetch user //#region Fetch user

View File

@ -16,7 +16,22 @@ import MkButton from '@/components/MkButton.vue';
const props = defineProps<{ const props = defineProps<{
modelValue: boolean; modelValue: boolean;
note: Misskey.entities.Note; text: string | null;
files: Misskey.entities.DriveFile[];
poll?: {
expiresAt: string | null;
multiple: boolean;
choices: {
isVoted: boolean;
text: string;
votes: number;
}[];
} | {
choices: string[];
multiple: boolean;
expiresAt: string | null;
expiredAfter: string | null;
};
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -25,9 +40,9 @@ const emit = defineEmits<{
const label = computed(() => { const label = computed(() => {
return concat([ return concat([
props.note.text ? [i18n.t('_cw.chars', { count: props.note.text.length })] : [], props.text ? [i18n.t('_cw.chars', { count: props.text.length })] : [],
props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length })] : [], props.files.length !== 0 ? [i18n.t('_cw.files', { count: props.files.length })] : [],
props.note.poll != null ? [i18n.ts.poll] : [], props.poll != null ? [i18n.ts.poll] : [],
] as string[][]).join(' / '); ] as string[][]).join(' / ');
}); });

View File

@ -62,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div style="container-type: inline-size;"> <div style="container-type: inline-size;">
<p v-if="appearNote.cw != null" :class="$style.cw"> <p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/> <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :note="appearNote" style="margin: 4px 0;"/> <MkCwButton v-model="showContent" :text="appearNote.text" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;"/>
</p> </p>
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]"> <div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
<div :class="$style.text"> <div :class="$style.text">
@ -150,6 +150,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
</I18n> </I18n>
</div> </div>
<div v-else>
<!--
MkDateSeparatedList uses TransitionGroup which requires single element in the child elements
so MkNote create empty div instead of no elements
-->
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>

View File

@ -68,7 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.noteContent"> <div :class="$style.noteContent">
<p v-if="appearNote.cw != null" :class="$style.cw"> <p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/> <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :note="appearNote"/> <MkCwButton v-model="showContent" :text="appearNote.text" :files="appearNote.files" :poll="appearNote.poll"/>
</p> </p>
<div v-show="appearNote.cw == null || showContent"> <div v-show="appearNote.cw == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>

View File

@ -11,7 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkUserName :user="user" :nowrap="true"/> <MkUserName :user="user" :nowrap="true"/>
</div> </div>
<div> <div>
<div> <p v-if="useCw" :class="$style.cw">
<Mfm v-if="cw != ''" :text="cw" :author="user" :nyaize="'respect'" :i="user" style="margin-right: 8px;"/>
<MkCwButton v-model="showContent" :text="text.trim()" :files="files" :poll="poll" style="margin: 4px 0;"/>
</p>
<div v-show="!useCw || showContent">
<Mfm :text="text.trim()" :author="user" :nyaize="'respect'" :i="user"/> <Mfm :text="text.trim()" :author="user" :nyaize="'respect'" :i="user"/>
</div> </div>
</div> </div>
@ -20,11 +24,23 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import MkCwButton from '@/components/MkCwButton.vue';
const showContent = ref(false);
const props = defineProps<{ const props = defineProps<{
text: string; text: string;
files: Misskey.entities.DriveFile[];
poll?: {
choices: string[];
multiple: boolean;
expiresAt: string | null;
expiredAfter: string | null;
};
useCw: boolean;
cw: string | null;
user: Misskey.entities.User; user: Misskey.entities.User;
}>(); }>();
</script> </script>
@ -53,6 +69,14 @@ const props = defineProps<{
min-width: 0; min-width: 0;
} }
.cw {
cursor: default;
display: block;
margin: 0;
padding: 0;
overflow-wrap: break-word;
}
.header { .header {
margin-bottom: 2px; margin-bottom: 2px;
font-weight: bold; font-weight: bold;

View File

@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div> <div>
<p v-if="note.cw != null" :class="$style.cw"> <p v-if="note.cw != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<MkCwButton v-model="showContent" :note="note"/> <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
</p> </p>
<div v-show="note.cw == null || showContent"> <div v-show="note.cw == null || showContent">
<MkSubNoteContent :class="$style.text" :note="note"/> <MkSubNoteContent :class="$style.text" :note="note"/>

View File

@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div> <div>
<p v-if="note.cw != null" :class="$style.cw"> <p v-if="note.cw != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/> <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :note="note"/> <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
</p> </p>
<div v-show="note.cw == null || showContent"> <div v-show="note.cw == null || showContent">
<MkSubNoteContent :class="$style.text" :note="note"/> <MkSubNoteContent :class="$style.text" :note="note"/>

View File

@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onUnmounted, onDeactivated, onMounted, computed, shallowRef, onActivated } from 'vue'; import { onUnmounted, onDeactivated, onMounted, computed, shallowRef, onActivated, watch } from 'vue';
import MkPagination, { Paging } from '@/components/MkPagination.vue'; import MkPagination, { Paging } from '@/components/MkPagination.vue';
import XNotification from '@/components/MkNotification.vue'; import XNotification from '@/components/MkNotification.vue';
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
@ -43,7 +43,7 @@ const props = defineProps<{
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
const pagination: Paging = defaultStore.state.useGroupedNotifications ? { let pagination = $computed(() => defaultStore.reactiveState.useGroupedNotifications.value ? {
endpoint: 'i/notifications-grouped' as const, endpoint: 'i/notifications-grouped' as const,
limit: 20, limit: 20,
params: computed(() => ({ params: computed(() => ({
@ -55,7 +55,7 @@ const pagination: Paging = defaultStore.state.useGroupedNotifications ? {
params: computed(() => ({ params: computed(() => ({
excludeTypes: props.excludeTypes ?? undefined, excludeTypes: props.excludeTypes ?? undefined,
})), })),
}; });
function onNotification(notification) { function onNotification(notification) {
const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false; const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false;

View File

@ -186,7 +186,7 @@ watch([$$(backed), $$(contentEl)], () => {
}); });
// ID // ID
watch(() => props.pagination.params, init, { deep: true }); watch(() => [props.pagination.endpoint, props.pagination.params], init, { deep: true });
watch(queue, (a, b) => { watch(queue, (a, b) => {
if (a.size === 0 && b.size === 0) return; if (a.size === 0 && b.size === 0) return;

View File

@ -73,7 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/> <XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :user="postAccount ?? $i"/> <MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/>
<div v-if="showingOptions" style="padding: 8px 16px;"> <div v-if="showingOptions" style="padding: 8px 16px;">
</div> </div>
<footer :class="$style.footer"> <footer :class="$style.footer">

View File

@ -140,7 +140,7 @@ export default function(props: MfmProps) {
case 'fn': { case 'fn': {
// TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
let style; let style: string | undefined;
switch (token.props.name) { switch (token.props.name) {
case 'tada': { case 'tada': {
const speed = validTime(token.props.args.speed) ?? '1s'; const speed = validTime(token.props.args.speed) ?? '1s';
@ -341,7 +341,7 @@ export default function(props: MfmProps) {
]); ]);
} }
} }
if (style == null) { if (style === undefined) {
return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']); return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']);
} else { } else {
return h('span', { return h('span', {

View File

@ -48,16 +48,12 @@ import { scrollToTop } from '@/scripts/scroll.js';
import { globalEvents } from '@/events.js'; import { globalEvents } from '@/events.js';
import { injectPageMetadata } from '@/scripts/page-metadata.js'; import { injectPageMetadata } from '@/scripts/page-metadata.js';
import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js'; import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
import { PageHeaderItem } from '@/types/page-header.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
tabs?: Tab[]; tabs?: Tab[];
tab?: string; tab?: string;
actions?: { actions?: PageHeaderItem[];
text: string;
icon: string;
highlighted?: boolean;
handler: (ev: MouseEvent) => void;
}[];
thin?: boolean; thin?: boolean;
displayMyAvatar?: boolean; displayMyAvatar?: boolean;
}>(), { }>(), {

View File

@ -12,7 +12,7 @@ import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { ui } from '@/config.js'; import { ui } from '@/config.js';
import { unisonReload } from '@/scripts/unison-reload.js'; import { unisonReload } from '@/scripts/unison-reload.js';
import {fetchCustomEmojis} from "@/custom-emojis.js"; import { clearCache } from './scripts/clear-cache.js';
export const navbarItemDef = reactive({ export const navbarItemDef = reactive({
notifications: { notifications: {
@ -172,17 +172,11 @@ export const navbarItemDef = reactive({
show: computed(() => $i != null), show: computed(() => $i != null),
to: `/@${$i?.username}`, to: `/@${$i?.username}`,
}, },
cacheclear: { cacheClear: {
title: i18n.ts.cacheClear,
icon: 'ti ti-trash', icon: 'ti ti-trash',
title: i18n.ts.clearCache, action: (ev) => {
action: async () => { clearCache();
os.waiting(); },
miLocalStorage.removeItem('locale');
miLocalStorage.removeItem('theme');
miLocalStorage.removeItem('emojis');
miLocalStorage.removeItem('lastEmojisFetchedAt');
await fetchCustomEmojis(true);
unisonReload();
}
}, },
}); });

View File

@ -39,6 +39,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.sensitiveWords }}</template> <template #label>{{ i18n.ts.sensitiveWords }}</template>
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template> <template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
</MkTextarea> </MkTextarea>
<MkTextarea v-model="hiddenTags">
<template #label>{{ i18n.ts.hiddenTags }}</template>
<template #caption>{{ i18n.ts.hiddenTagsDescription }}</template>
</MkTextarea>
</div> </div>
</FormSuspense> </FormSuspense>
</MkSpacer> </MkSpacer>
@ -72,6 +77,7 @@ import FormLink from '@/components/form/link.vue';
let enableRegistration: boolean = $ref(false); let enableRegistration: boolean = $ref(false);
let emailRequiredForSignup: boolean = $ref(false); let emailRequiredForSignup: boolean = $ref(false);
let sensitiveWords: string = $ref(''); let sensitiveWords: string = $ref('');
let hiddenTags: string = $ref('');
let preservedUsernames: string = $ref(''); let preservedUsernames: string = $ref('');
let tosUrl: string | null = $ref(null); let tosUrl: string | null = $ref(null);
let privacyPolicyUrl: string | null = $ref(null); let privacyPolicyUrl: string | null = $ref(null);
@ -81,6 +87,7 @@ async function init() {
enableRegistration = !meta.disableRegistration; enableRegistration = !meta.disableRegistration;
emailRequiredForSignup = meta.emailRequiredForSignup; emailRequiredForSignup = meta.emailRequiredForSignup;
sensitiveWords = meta.sensitiveWords.join('\n'); sensitiveWords = meta.sensitiveWords.join('\n');
hiddenTags = meta.hiddenTags.join('\n');
preservedUsernames = meta.preservedUsernames.join('\n'); preservedUsernames = meta.preservedUsernames.join('\n');
tosUrl = meta.tosUrl; tosUrl = meta.tosUrl;
privacyPolicyUrl = meta.privacyPolicyUrl; privacyPolicyUrl = meta.privacyPolicyUrl;
@ -93,6 +100,7 @@ function save() {
tosUrl, tosUrl,
privacyPolicyUrl, privacyPolicyUrl,
sensitiveWords: sensitiveWords.split('\n'), sensitiveWords: sensitiveWords.split('\n'),
hiddenTags: hiddenTags.split('\n'),
preservedUsernames: preservedUsernames.split('\n'), preservedUsernames: preservedUsernames.split('\n'),
}).then(() => { }).then(() => {
fetchInstance(); fetchInstance();

View File

@ -86,6 +86,9 @@ import { defaultStore } from '@/store.js';
import MkNote from '@/components/MkNote.vue'; import MkNote from '@/components/MkNote.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { PageHeaderItem } from '@/types/page-header.js';
import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
const router = useRouter(); const router = useRouter();
@ -167,7 +170,19 @@ async function search() {
const headerActions = $computed(() => { const headerActions = $computed(() => {
if (channel && channel.userId) { if (channel && channel.userId) {
const share = { const headerItems: PageHeaderItem[] = [];
headerItems.push({
icon: 'ti ti-link',
text: i18n.ts.copyUrl,
handler: async (): Promise<void> => {
copyToClipboard(`${url}/channels/${channel.id}`);
os.success();
},
});
if (isSupportShare()) {
headerItems.push({
icon: 'ti ti-share', icon: 'ti ti-share',
text: i18n.ts.share, text: i18n.ts.share,
handler: async (): Promise<void> => { handler: async (): Promise<void> => {
@ -177,14 +192,18 @@ const headerActions = $computed(() => {
url: `${url}/channels/${channel.id}`, url: `${url}/channels/${channel.id}`,
}); });
}, },
}; });
}
const canEdit = ($i && $i.id === channel.userId) || iAmModerator; if (($i && $i.id === channel.userId) || iAmModerator) {
return canEdit ? [share, { headerItems.push({
icon: 'ti ti-settings', icon: 'ti ti-settings',
text: i18n.ts.edit, text: i18n.ts.edit,
handler: edit, handler: edit,
}] : [share]; });
}
return headerItems.length > 0 ? headerItems : null;
} else { } else {
return null; return null;
} }

View File

@ -36,6 +36,8 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { url } from '@/config.js'; import { url } from '@/config.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { clipsCache } from '@/cache'; import { clipsCache } from '@/cache';
import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
const props = defineProps<{ const props = defineProps<{
clipId: string, clipId: string,
@ -118,6 +120,13 @@ const headerActions = $computed(() => clip && isOwned ? [{
clipsCache.delete(); clipsCache.delete();
}, },
}, ...(clip.isPublic ? [{ }, ...(clip.isPublic ? [{
icon: 'ti ti-link',
text: i18n.ts.copyUrl,
handler: async (): Promise<void> => {
copyToClipboard(`${url}/clips/${clip.id}`);
os.success();
},
}] : []), ...(clip.isPublic && isSupportShare() ? [{
icon: 'ti ti-share', icon: 'ti ti-share',
text: i18n.ts.share, text: i18n.ts.share,
handler: async (): Promise<void> => { handler: async (): Promise<void> => {

View File

@ -54,7 +54,7 @@ function menu(ev) {
os.apiGet('emoji', { name: props.emoji.name }).then(res => { os.apiGet('emoji', { name: props.emoji.name }).then(res => {
os.alert({ os.alert({
type: 'info', type: 'info',
text: `License: ${res.license}`, text: `Name: ${res.name}\nAliases: ${res.aliases.join(' ')}\nCategory: ${res.category}\nisSensitive: ${res.isSensitive}\nlocalOnly: ${res.localOnly}\nLicense: ${res.license}\nURL: ${res.url}`,
}); });
}); });
}, },

View File

@ -18,7 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" asLike class="button" rounded primary @click="unlike()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton> <MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" asLike class="button" rounded primary @click="unlike()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
<MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton> <MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
<MkButton v-tooltip="i18n.ts.shareWithNote" class="button" rounded @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></MkButton> <MkButton v-tooltip="i18n.ts.shareWithNote" class="button" rounded @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></MkButton>
<MkButton v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton> <MkButton v-tooltip="i18n.ts.copyLink" class="button" rounded @click="copyLink"><i class="ti ti-link ti-fw"></i></MkButton>
<MkButton v-if="isSupportShare()" v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton>
</div> </div>
</div> </div>
<div v-else :class="$style.ready"> <div v-else :class="$style.ready">
@ -70,6 +71,8 @@ import MkFolder from '@/components/MkFolder.vue';
import MkCode from '@/components/MkCode.vue'; import MkCode from '@/components/MkCode.vue';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
const props = defineProps<{ const props = defineProps<{
id: string; id: string;
@ -89,6 +92,11 @@ function fetchFlash() {
}); });
} }
function copyLink() {
copyToClipboard(`${url}/play/${flash.id}`);
os.success();
}
function share() { function share() {
navigator.share({ navigator.share({
title: flash.title, title: flash.title,

View File

@ -29,7 +29,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="other"> <div class="other">
<button v-if="$i && $i.id === post.user.id" v-tooltip="i18n.ts.edit" v-click-anime class="_button" @click="edit"><i class="ti ti-pencil ti-fw"></i></button> <button v-if="$i && $i.id === post.user.id" v-tooltip="i18n.ts.edit" v-click-anime class="_button" @click="edit"><i class="ti ti-pencil ti-fw"></i></button>
<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button> <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
<button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button> <button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ti ti-link ti-fw"></i></button>
<button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
</div> </div>
</div> </div>
<div class="user"> <div class="user">
@ -74,6 +75,8 @@ import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
const router = useRouter(); const router = useRouter();
@ -102,6 +105,11 @@ function fetchPost() {
}); });
} }
function copyLink() {
copyToClipboard(`${url}/gallery/${post.id}`);
os.success();
}
function share() { function share() {
navigator.share({ navigator.share({
title: post.title, title: post.title,

View File

@ -34,7 +34,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div class="other"> <div class="other">
<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button> <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
<button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button> <button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ti ti-link ti-fw"></i></button>
<button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
</div> </div>
</div> </div>
<div class="user"> <div class="user">
@ -90,6 +91,8 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { pageViewInterruptors, defaultStore } from '@/store.js'; import { pageViewInterruptors, defaultStore } from '@/store.js';
import { deepClone } from '@/scripts/clone.js'; import { deepClone } from '@/scripts/clone.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
const props = defineProps<{ const props = defineProps<{
pageName: string; pageName: string;
@ -136,6 +139,11 @@ function share() {
}); });
} }
function copyLink() {
copyToClipboard(`${url}/@${page.user.username}/pages/${page.name}`);
os.success();
}
function shareWithNote() { function shareWithNote() {
os.post({ os.post({
initialText: `${page.title || page.name} ${url}/@${page.user.username}/pages/${page.name}`, initialText: `${page.title || page.name} ${url}/@${page.user.username}/pages/${page.name}`,

View File

@ -32,13 +32,11 @@ import { i18n } from '@/i18n.js';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import MkSuperMenu from '@/components/MkSuperMenu.vue'; import MkSuperMenu from '@/components/MkSuperMenu.vue';
import { signout, $i } from '@/account.js'; import { signout, $i } from '@/account.js';
import { unisonReload } from '@/scripts/unison-reload.js'; import { clearCache } from '@/scripts/clear-cache.js';
import { instance } from '@/instance.js'; import { instance } from '@/instance.js';
import { useRouter } from '@/router.js'; import { useRouter } from '@/router.js';
import { definePageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js'; import { definePageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { miLocalStorage } from '@/local-storage.js';
import { fetchCustomEmojis } from '@/custom-emojis.js';
const indexInfo = { const indexInfo = {
title: i18n.ts.settings, title: i18n.ts.settings,
@ -181,13 +179,7 @@ const menuDef = computed(() => [{
icon: 'ti ti-trash', icon: 'ti ti-trash',
text: i18n.ts.clearCache, text: i18n.ts.clearCache,
action: async () => { action: async () => {
os.waiting(); await clearCache();
miLocalStorage.removeItem('locale');
miLocalStorage.removeItem('theme');
miLocalStorage.removeItem('emojis');
miLocalStorage.removeItem('lastEmojisFetchedAt');
await fetchCustomEmojis(true);
unisonReload();
}, },
}, { }, {
type: 'button', type: 'button',

View File

@ -7,8 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m"> <div class="_gaps_m">
<MkSelect v-model="type"> <MkSelect v-model="type">
<template #label>{{ i18n.ts.sound }}</template> <template #label>{{ i18n.ts.sound }}</template>
<option v-for="x in soundsTypes" :key="x" :value="x">{{ x == null ? i18n.ts.none : x }}</option> <option v-for="x in soundsTypes" :key="x ?? 'null'" :value="x">{{ getSoundTypeName(x) }}</option>
</MkSelect> </MkSelect>
<div v-if="type === '_driveFile_'" :class="$style.fileSelectorRoot">
<MkButton :class="$style.fileSelectorButton" inline rounded primary @click="selectSound">{{ i18n.ts.selectFile }}</MkButton>
<div :class="['_nowrap', !fileUrl && $style.fileNotSelected]">{{ friendlyFileName }}</div>
</div>
<MkRange v-model="volume" :min="0" :max="1" :step="0.05" :textConverter="(v) => `${Math.floor(v * 100)}%`"> <MkRange v-model="volume" :min="0" :max="1" :step="0.05" :textConverter="(v) => `${Math.floor(v * 100)}%`">
<template #label>{{ i18n.ts.volume }}</template> <template #label>{{ i18n.ts.volume }}</template>
</MkRange> </MkRange>
@ -21,30 +25,149 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { ref, computed } from 'vue';
import type { SoundType } from '@/scripts/sound.js';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkRange from '@/components/MkRange.vue'; import MkRange from '@/components/MkRange.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { playFile, soundsTypes } from '@/scripts/sound.js'; import * as os from '@/os.js';
import { playFile, soundsTypes, getSoundDuration } from '@/scripts/sound.js';
import { selectFile } from '@/scripts/select-file.js';
const props = defineProps<{ const props = defineProps<{
type: string; type: SoundType;
fileId?: string;
fileUrl?: string;
volume: number; volume: number;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'update', result: { type: string; volume: number; }): void; (ev: 'update', result: { type: SoundType; fileId?: string; fileUrl?: string; volume: number; }): void;
}>(); }>();
let type = $ref(props.type); const type = ref<SoundType>(props.type);
let volume = $ref(props.volume); const fileId = ref(props.fileId);
const fileUrl = ref(props.fileUrl);
const fileName = ref<string>('');
const volume = ref(props.volume);
if (type.value === '_driveFile_' && fileId.value) {
const apiRes = await os.api('drive/files/show', {
fileId: fileId.value,
});
fileName.value = apiRes.name;
}
function getSoundTypeName(f: SoundType): string {
switch (f) {
case null:
return i18n.ts.none;
case '_driveFile_':
return i18n.ts._soundSettings.driveFile;
default:
return f;
}
}
const friendlyFileName = computed<string>(() => {
if (fileName.value) {
return fileName.value;
}
if (fileUrl.value) {
return fileUrl.value;
}
return i18n.ts._soundSettings.driveFileWarn;
});
function selectSound(ev) {
selectFile(ev.currentTarget ?? ev.target, i18n.ts._soundSettings.driveFile).then(async (file) => {
if (!file.type.startsWith('audio')) {
os.alert({
type: 'warning',
title: i18n.ts._soundSettings.driveFileTypeWarn,
text: i18n.ts._soundSettings.driveFileTypeWarnDescription,
});
return;
}
const duration = await getSoundDuration(file.url);
if (duration >= 2000) {
const { canceled } = await os.confirm({
type: 'warning',
title: i18n.ts._soundSettings.driveFileDurationWarn,
text: i18n.ts._soundSettings.driveFileDurationWarnDescription,
okText: i18n.ts.continue,
cancelText: i18n.ts.cancel,
});
if (canceled) return;
}
fileUrl.value = file.url;
fileName.value = file.name;
fileId.value = file.id;
});
}
function listen() { function listen() {
playFile(type, volume); if (type.value === '_driveFile_' && (!fileUrl.value || !fileId.value)) {
os.alert({
type: 'warning',
text: i18n.ts._soundSettings.driveFileWarn,
});
return;
}
playFile(type.value === '_driveFile_' ? {
type: '_driveFile_',
fileId: fileId.value as string,
fileUrl: fileUrl.value as string,
volume: volume.value,
} : {
type: type.value,
volume: volume.value,
});
} }
function save() { function save() {
emit('update', { type, volume }); if (type.value === '_driveFile_' && !fileUrl.value) {
os.alert({
type: 'warning',
text: i18n.ts._soundSettings.driveFileWarn,
});
return;
}
if (type.value !== '_driveFile_') {
fileUrl.value = undefined;
fileName.value = '';
fileId.value = undefined;
}
emit('update', {
type: type.value,
fileId: fileId.value,
fileUrl: fileUrl.value,
volume: volume.value,
});
os.success();
} }
</script> </script>
<style module>
.fileSelectorRoot {
display: flex;
align-items: center;
gap: 8px;
}
.fileSelectorButton {
flex-shrink: 0;
}
.fileNotSelected {
font-weight: 700;
color: var(--infoWarnFg);
}
</style>

View File

@ -18,11 +18,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<FormSection> <FormSection>
<template #label>{{ i18n.ts.sounds }}</template> <template #label>{{ i18n.ts.sounds }}</template>
<div class="_gaps_s"> <div class="_gaps_s">
<MkFolder v-for="type in soundsKeys" :key="type"> <MkFolder v-for="type in operationTypes" :key="type">
<template #label>{{ i18n.t('_sfx.' + type) }}</template> <template #label>{{ i18n.t('_sfx.' + type) }}</template>
<template #suffix>{{ sounds[type].type ?? i18n.ts.none }}</template> <template #suffix>{{ getSoundTypeName(sounds[type].type) }}</template>
<XSound :type="sounds[type].type" :volume="sounds[type].volume" @update="(res) => updated(type, res)"/> <XSound :type="sounds[type].type" :volume="sounds[type].volume" :fileId="sounds[type].fileId" :fileUrl="sounds[type].fileUrl" @update="(res) => updated(type, res)"/>
</MkFolder> </MkFolder>
</div> </div>
</FormSection> </FormSection>
@ -33,6 +33,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { Ref, computed, ref } from 'vue'; import { Ref, computed, ref } from 'vue';
import type { SoundType, OperationType } from '@/scripts/sound.js';
import type { SoundStore } from '@/store.js';
import XSound from './sounds.sound.vue'; import XSound from './sounds.sound.vue';
import MkRange from '@/components/MkRange.vue'; import MkRange from '@/components/MkRange.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
@ -40,6 +42,7 @@ import FormSection from '@/components/form/section.vue';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import { operationTypes } from '@/scripts/sound.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
@ -47,9 +50,7 @@ const notUseSound = computed(defaultStore.makeGetterSetter('sound_notUseSound'))
const useSoundOnlyWhenActive = computed(defaultStore.makeGetterSetter('sound_useSoundOnlyWhenActive')); const useSoundOnlyWhenActive = computed(defaultStore.makeGetterSetter('sound_useSoundOnlyWhenActive'));
const masterVolume = computed(defaultStore.makeGetterSetter('sound_masterVolume')); const masterVolume = computed(defaultStore.makeGetterSetter('sound_masterVolume'));
const soundsKeys = ['note', 'noteMy', 'notification', 'antenna', 'channel', 'reaction'] as const; const sounds = ref<Record<OperationType, Ref<SoundStore>>>({
const sounds = ref<Record<typeof soundsKeys[number], Ref<any>>>({
note: defaultStore.reactiveState.sound_note, note: defaultStore.reactiveState.sound_note,
noteMy: defaultStore.reactiveState.sound_noteMy, noteMy: defaultStore.reactiveState.sound_noteMy,
notification: defaultStore.reactiveState.sound_notification, notification: defaultStore.reactiveState.sound_notification,
@ -58,9 +59,22 @@ const sounds = ref<Record<typeof soundsKeys[number], Ref<any>>>({
reaction: defaultStore.reactiveState.sound_reaction, reaction: defaultStore.reactiveState.sound_reaction,
}); });
function getSoundTypeName(f: SoundType): string {
switch (f) {
case null:
return i18n.ts.none;
case '_driveFile_':
return i18n.ts._soundSettings.driveFile;
default:
return f;
}
}
async function updated(type: keyof typeof sounds.value, sound) { async function updated(type: keyof typeof sounds.value, sound) {
const v = { const v: SoundStore = {
type: sound.type, type: sound.type,
fileId: sound.fileId,
fileUrl: sound.fileUrl,
volume: sound.volume, volume: sound.volume,
}; };

View File

@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:renote="renote" :renote="renote"
:initialVisibleUsers="visibleUsers" :initialVisibleUsers="visibleUsers"
class="_panel" class="_panel"
@posted="state = 'posted'" @posted="onPosted"
/> />
<div v-else-if="state === 'posted'" class="_buttonsCenter"> <div v-else-if="state === 'posted'" class="_buttonsCenter">
<MkButton primary @click="close">{{ i18n.ts.close }}</MkButton> <MkButton primary @click="close">{{ i18n.ts.close }}</MkButton>
@ -32,20 +32,20 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
// SPECIFICATION: https://misskey-hub.net/docs/features/share-form.html // SPECIFICATION: https://misskey-hub.net/docs/features/share-form.html
import { } from 'vue'; import { ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkPostForm from '@/components/MkPostForm.vue'; import MkPostForm from '@/components/MkPostForm.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { mainRouter } from '@/router.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import { postMessageToParentWindow } from '@/scripts/post-message.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const localOnlyQuery = urlParams.get('localOnly'); const localOnlyQuery = urlParams.get('localOnly');
const visibilityQuery = urlParams.get('visibility') as typeof Misskey.noteVisibilities[number]; const visibilityQuery = urlParams.get('visibility') as typeof Misskey.noteVisibilities[number];
let state = $ref('fetching' as 'fetching' | 'writing' | 'posted'); const state = ref<'fetching' | 'writing' | 'posted'>('fetching');
let title = $ref(urlParams.get('title')); let title = $ref(urlParams.get('title'));
const text = urlParams.get('text'); const text = urlParams.get('text');
const url = urlParams.get('url'); const url = urlParams.get('url');
@ -144,7 +144,7 @@ async function init() {
}); });
} }
state = 'writing'; state.value = 'writing';
} }
init(); init();
@ -162,6 +162,11 @@ function goToMisskey(): void {
location.href = '/'; location.href = '/';
} }
function onPosted(): void {
state.value = 'posted';
postMessageToParentWindow('misskey:shareForm:shareCompleted');
}
const headerActions = $computed(() => []); const headerActions = $computed(() => []);
const headerTabs = $computed(() => []); const headerTabs = $computed(() => []);

View File

@ -0,0 +1,14 @@
import { unisonReload } from '@/scripts/unison-reload.js';
import * as os from '@/os.js';
import { miLocalStorage } from '@/local-storage.js';
import { fetchCustomEmojis } from '@/custom-emojis.js';
export async function clearCache() {
os.waiting();
miLocalStorage.removeItem('locale');
miLocalStorage.removeItem('theme');
miLocalStorage.removeItem('emojis');
miLocalStorage.removeItem('lastEmojisFetchedAt');
await fetchCustomEmojis(true);
unisonReload();
}

View File

@ -18,6 +18,7 @@ import { getUserMenu } from '@/scripts/get-user-menu.js';
import { clipsCache } from '@/cache.js'; import { clipsCache } from '@/cache.js';
import { MenuItem } from '@/types/menu.js'; import { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue'; import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { isSupportShare } from '@/scripts/navigator.js';
export async function getNoteClipMenu(props: { export async function getNoteClipMenu(props: {
note: Misskey.entities.Note; note: Misskey.entities.Note;
@ -284,11 +285,11 @@ export function getNoteMenu(props: {
window.open(appearNote.url ?? appearNote.uri, '_blank'); window.open(appearNote.url ?? appearNote.uri, '_blank');
}, },
} : undefined, } : undefined,
{ ...(isSupportShare() ? [{
icon: 'ti ti-share', icon: 'ti ti-share',
text: i18n.ts.share, text: i18n.ts.share,
action: share, action: share,
}, }] : []),
$i && $i.policies.canUseTranslator && instance.translatorAvailable ? { $i && $i.policies.canUseTranslator && instance.translatorAvailable ? {
icon: 'ti ti-language-hiragana', icon: 'ti ti-language-hiragana',
text: i18n.ts.translate, text: i18n.ts.translate,
@ -493,7 +494,7 @@ export function getRenoteMenu(props: {
}]); }]);
} }
if (!appearNote.channel || appearNote.channel?.allowRenoteToExternal) { if (!appearNote.channel || appearNote.channel.allowRenoteToExternal) {
normalRenoteItems.push(...[{ normalRenoteItems.push(...[{
text: i18n.ts.renote, text: i18n.ts.renote,
icon: 'ti ti-repeat', icon: 'ti ti-repeat',

View File

@ -0,0 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function isSupportShare(): boolean {
return 'share' in navigator;
}

View File

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const postMessageEventTypes = [
'misskey:shareForm:shareCompleted',
] as const;
export type PostMessageEventType = typeof postMessageEventTypes[number];
export type MiPostMessageEvent = {
type: PostMessageEventType;
payload?: any;
};
/**
*
*/
export function postMessageToParentWindow(type: PostMessageEventType, payload?: any): void {
window.postMessage({
type,
payload,
}, '*');
}

View File

@ -3,14 +3,22 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { SoundStore } from '@/store.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import * as os from '@/os.js';
let ctx: AudioContext; let ctx: AudioContext;
const cache = new Map<string, AudioBuffer>(); const cache = new Map<string, AudioBuffer>();
let canPlay = true; let canPlay = true;
export const soundsTypes = [ export const soundsTypes = [
// 音声なし
null, null,
// ドライブの音声
'_driveFile_',
// プリインストール
'syuilo/n-aec', 'syuilo/n-aec',
'syuilo/n-aec-4va', 'syuilo/n-aec-4va',
'syuilo/n-aec-4vb', 'syuilo/n-aec-4vb',
@ -64,32 +72,96 @@ export const soundsTypes = [
'noizenecio/kick_gaba7', 'noizenecio/kick_gaba7',
] as const; ] as const;
export async function loadAudio(file: string, useCache = true) { export const operationTypes = [
'noteMy',
'note',
'antenna',
'channel',
'notification',
'reaction',
] as const;
/** サウンドの種類 */
export type SoundType = typeof soundsTypes[number];
/** スプライトの種類 */
export type OperationType = typeof operationTypes[number];
/**
*
* @param soundStore
* @param options `useCache`: `true`
*/
export async function loadAudio(soundStore: SoundStore, options?: { useCache?: boolean; }) {
if (_DEV_) console.log('loading audio. opts:', options);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) {
return;
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (ctx == null) { if (ctx == null) {
ctx = new AudioContext(); ctx = new AudioContext();
} }
if (useCache && cache.has(file)) { if (options?.useCache ?? true) {
return cache.get(file)!; if (soundStore.type === '_driveFile_' && cache.has(soundStore.fileId)) {
if (_DEV_) console.log('use cache');
return cache.get(soundStore.fileId) as AudioBuffer;
} else if (cache.has(soundStore.type)) {
if (_DEV_) console.log('use cache');
return cache.get(soundStore.type) as AudioBuffer;
}
}
let response: Response;
if (soundStore.type === '_driveFile_') {
try {
response = await fetch(soundStore.fileUrl);
} catch (err) {
try {
// URLが変わっている可能性があるのでドライブ側からURLを取得するフォールバック
const apiRes = await os.api('drive/files/show', {
fileId: soundStore.fileId,
});
response = await fetch(apiRes.url);
} catch (fbErr) {
// それでも無理なら諦める
return;
}
}
} else {
try {
response = await fetch(`/client-assets/sounds/${soundStore.type}.mp3`);
} catch (err) {
return;
}
} }
const response = await fetch(`/client-assets/sounds/${file}.mp3`);
const arrayBuffer = await response.arrayBuffer(); const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await ctx.decodeAudioData(arrayBuffer); const audioBuffer = await ctx.decodeAudioData(arrayBuffer);
if (useCache) { if (options?.useCache ?? true) {
cache.set(file, audioBuffer); if (soundStore.type === '_driveFile_') {
cache.set(soundStore.fileId, audioBuffer);
} else {
cache.set(soundStore.type, audioBuffer);
}
} }
return audioBuffer; return audioBuffer;
} }
export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notification' | 'reaction') { /**
const sound = defaultStore.state[`sound_${type}`]; *
if (_DEV_) console.log('play', type, sound); * @param type
*/
export function play(operationType: OperationType) {
const sound = defaultStore.state[`sound_${operationType}`];
if (_DEV_) console.log('play', operationType, sound);
if (sound.type == null || !canPlay) return; if (sound.type == null || !canPlay) return;
canPlay = false; canPlay = false;
playFile(sound.type, sound.volume).then(() => { playFile(sound).finally(() => {
// ごく短時間に音が重複しないように // ごく短時間に音が重複しないように
setTimeout(() => { setTimeout(() => {
canPlay = true; canPlay = true;
@ -97,9 +169,14 @@ export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notifica
}); });
} }
export async function playFile(file: string, volume: number) { /**
const buffer = await loadAudio(file); *
createSourceNode(buffer, volume)?.start(); * @param soundStore
*/
export async function playFile(soundStore: SoundStore) {
const buffer = await loadAudio(soundStore);
if (!buffer) return;
createSourceNode(buffer, soundStore.volume)?.start();
} }
export function createSourceNode(buffer: AudioBuffer, volume: number) : AudioBufferSourceNode | null { export function createSourceNode(buffer: AudioBuffer, volume: number) : AudioBufferSourceNode | null {
@ -118,6 +195,27 @@ export function createSourceNode(buffer: AudioBuffer, volume: number) : AudioBuf
return soundSource; return soundSource;
} }
/**
*
* @param file URLIDではない
*/
export async function getSoundDuration(file: string): Promise<number> {
const audioEl = document.createElement('audio');
audioEl.src = file;
return new Promise((resolve) => {
const si = setInterval(() => {
if (audioEl.readyState > 0) {
resolve(audioEl.duration * 1000);
clearInterval(si);
audioEl.remove();
}
}, 100);
});
}
/**
*
*/
export function isMute(): boolean { export function isMute(): boolean {
if (defaultStore.state.sound_notUseSound) { if (defaultStore.state.sound_notUseSound) {
// サウンドを出力しない // サウンドを出力しない

View File

@ -6,6 +6,7 @@
import { markRaw, ref } from 'vue'; import { markRaw, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { miLocalStorage } from './local-storage.js'; import { miLocalStorage } from './local-storage.js';
import type { SoundType } from '@/scripts/sound.js';
import { Storage } from '@/pizzax.js'; import { Storage } from '@/pizzax.js';
interface PostFormAction { interface PostFormAction {
@ -35,6 +36,22 @@ interface PageViewInterruptor {
handler: (page: Misskey.entities.Page) => unknown; handler: (page: Misskey.entities.Page) => unknown;
} }
/** サウンド設定 */
export type SoundStore = {
type: Exclude<SoundType, '_driveFile_'>;
volume: number;
} | {
type: '_driveFile_';
/** ドライブのファイルID */
fileId: string;
/** ファイルURLこちらが優先される */
fileUrl: string;
volume: number;
}
export const postFormActions: PostFormAction[] = []; export const postFormActions: PostFormAction[] = [];
export const userActions: UserAction[] = []; export const userActions: UserAction[] = [];
export const noteActions: NoteAction[] = []; export const noteActions: NoteAction[] = [];
@ -500,27 +517,27 @@ export const defaultStore = markRaw(new Storage('base', {
}, },
sound_note: { sound_note: {
where: 'device', where: 'device',
default: { type: 'syuilo/n-aec', volume: 1 }, default: { type: 'syuilo/n-aec', volume: 1 } as SoundStore,
}, },
sound_noteMy: { sound_noteMy: {
where: 'device', where: 'device',
default: { type: 'syuilo/n-cea-4va', volume: 1 }, default: { type: 'syuilo/n-cea-4va', volume: 1 } as SoundStore,
}, },
sound_notification: { sound_notification: {
where: 'device', where: 'device',
default: { type: 'syuilo/n-ea', volume: 1 }, default: { type: 'syuilo/n-ea', volume: 1 } as SoundStore,
}, },
sound_antenna: { sound_antenna: {
where: 'device', where: 'device',
default: { type: 'syuilo/triple', volume: 1 }, default: { type: 'syuilo/triple', volume: 1 } as SoundStore,
}, },
sound_channel: { sound_channel: {
where: 'device', where: 'device',
default: { type: 'syuilo/square-pico', volume: 1 }, default: { type: 'syuilo/square-pico', volume: 1 } as SoundStore,
}, },
sound_reaction: { sound_reaction: {
where: 'device', where: 'device',
default: { type: 'syuilo/bubble2', volume: 1 }, default: { type: 'syuilo/bubble2', volume: 1 } as SoundStore,
}, },
})); }));

View File

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export type PageHeaderItem = {
text: string;
icon: string;
highlighted?: boolean;
handler: (ev: MouseEvent) => void;
};

View File

@ -0,0 +1,127 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkContainer :showHeader="widgetProps.showHeader" class="mkw-bdayfollowings">
<template #icon><i class="ti ti-cake"></i></template>
<template #header>{{ i18n.ts._widgets.birthdayFollowings }}</template>
<div :class="$style.bdayFRoot">
<MkLoading v-if="fetching"/>
<div v-else-if="users.length > 0" :class="$style.bdayFGrid">
<MkAvatar v-for="user in users" :key="user.id" :user="user.followee" link preview></MkAvatar>
</div>
<div v-else :class="$style.bdayFFallback">
<img :src="infoImageUrl" class="_ghost" :class="$style.bdayFFallbackImage"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</div>
</MkContainer>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import { GetFormResultType } from '@/scripts/form.js';
import MkContainer from '@/components/MkContainer.vue';
import * as os from '@/os.js';
import { useInterval } from '@/scripts/use-interval.js';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
import { $i } from '@/account.js';
const name = i18n.ts._widgets.birthdayFollowings;
const widgetPropsDef = {
showHeader: {
type: 'boolean' as const,
default: true,
},
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
const props = defineProps<WidgetComponentProps<WidgetProps>>();
const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
props,
emit,
);
const users = ref<Misskey.entities.FollowingFolloweePopulated[]>([]);
const fetching = ref(true);
let lastFetchedAt = '1970-01-01';
const fetch = () => {
if (!$i) {
users.value = [];
fetching.value = false;
return;
}
const lfAtD = new Date(lastFetchedAt);
lfAtD.setHours(0, 0, 0, 0);
const now = new Date();
now.setHours(0, 0, 0, 0);
if (now > lfAtD) {
os.api('users/following', {
limit: 18,
birthday: now.toISOString(),
userId: $i.id,
}).then(res => {
users.value = res;
fetching.value = false;
});
lastFetchedAt = now.toISOString();
}
};
useInterval(fetch, 1000 * 60, {
immediate: true,
afterMounted: true,
});
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>
<style lang="scss" module>
.bdayFRoot {
overflow: hidden;
min-height: calc(calc(calc(50px * 3) - 8px) + calc(var(--margin) * 2));
}
.bdayFGrid {
display: grid;
grid-template-columns: repeat(6, 42px);
grid-template-rows: repeat(3, 42px);
place-content: center;
gap: 8px;
margin: var(--margin) auto;
}
.bdayFFallback {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.bdayFFallbackImage {
height: 96px;
width: auto;
max-width: 90%;
margin-bottom: 8px;
border-radius: var(--radius);
}
</style>

View File

@ -104,7 +104,13 @@ let jammedAudioBuffer: AudioBuffer | null = $ref(null);
let jammedSoundNodePlaying: boolean = $ref(false); let jammedSoundNodePlaying: boolean = $ref(false);
if (defaultStore.state.sound_masterVolume) { if (defaultStore.state.sound_masterVolume) {
sound.loadAudio('syuilo/queue-jammed').then(buf => jammedAudioBuffer = buf); sound.loadAudio({
type: 'syuilo/queue-jammed',
volume: 1,
}).then(buf => {
if (!buf) throw new Error('[WidgetJobQueue] Failed to initialize AudioBuffer');
jammedAudioBuffer = buf;
});
} }
for (const domain of ['inbox', 'deliver']) { for (const domain of ['inbox', 'deliver']) {

View File

@ -35,6 +35,7 @@ export default function(app: App) {
app.component('WidgetAichan', defineAsyncComponent(() => import('./WidgetAichan.vue'))); app.component('WidgetAichan', defineAsyncComponent(() => import('./WidgetAichan.vue')));
app.component('WidgetUserList', defineAsyncComponent(() => import('./WidgetUserList.vue'))); app.component('WidgetUserList', defineAsyncComponent(() => import('./WidgetUserList.vue')));
app.component('WidgetClicker', defineAsyncComponent(() => import('./WidgetClicker.vue'))); app.component('WidgetClicker', defineAsyncComponent(() => import('./WidgetClicker.vue')));
app.component('WidgetBirthdayFollowings', defineAsyncComponent(() => import('./WidgetBirthdayFollowings.vue')));
} }
export const widgets = [ export const widgets = [
@ -67,4 +68,5 @@ export const widgets = [
'aichan', 'aichan',
'userList', 'userList',
'clicker', 'clicker',
'birthdayFollowings',
]; ];

View File

@ -150,12 +150,16 @@ export function getConfig(): UserConfig {
test: { test: {
environment: 'happy-dom', environment: 'happy-dom',
deps: { deps: {
inline: [ optimizer: {
web: {
include: [
// XXX: misskey-dev/browser-image-resizer has no "type": "module" // XXX: misskey-dev/browser-image-resizer has no "type": "module"
'browser-image-resizer', 'browser-image-resizer',
], ],
}, },
}, },
},
},
}; };
} }

View File

@ -22,11 +22,11 @@
"devDependencies": { "devDependencies": {
"@microsoft/api-extractor": "7.38.3", "@microsoft/api-extractor": "7.38.3",
"@swc/jest": "0.2.29", "@swc/jest": "0.2.29",
"@types/jest": "29.5.8", "@types/jest": "29.5.10",
"@types/node": "20.9.1", "@types/node": "20.10.0",
"@typescript-eslint/eslint-plugin": "6.11.0", "@typescript-eslint/eslint-plugin": "6.12.0",
"@typescript-eslint/parser": "6.11.0", "@typescript-eslint/parser": "6.12.0",
"eslint": "8.53.0", "eslint": "8.54.0",
"jest": "29.7.0", "jest": "29.7.0",
"jest-fetch-mock": "3.0.3", "jest-fetch-mock": "3.0.3",
"jest-websocket-mock": "2.5.0", "jest-websocket-mock": "2.5.0",
@ -39,7 +39,7 @@
], ],
"dependencies": { "dependencies": {
"@swc/cli": "0.1.63", "@swc/cli": "0.1.63",
"@swc/core": "1.3.96", "@swc/core": "1.3.99",
"eventemitter3": "5.0.1", "eventemitter3": "5.0.1",
"reconnecting-websocket": "4.4.0" "reconnecting-websocket": "4.4.0"
} }

View File

@ -9,14 +9,14 @@
"lint": "pnpm typecheck && pnpm eslint" "lint": "pnpm typecheck && pnpm eslint"
}, },
"dependencies": { "dependencies": {
"esbuild": "0.19.5", "esbuild": "0.19.8",
"idb-keyval": "6.2.1", "idb-keyval": "6.2.1",
"misskey-js": "workspace:*" "misskey-js": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/parser": "6.11.0", "@typescript-eslint/parser": "6.12.0",
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67", "@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67",
"eslint": "8.53.0", "eslint": "8.54.0",
"eslint-plugin-import": "2.29.0", "eslint-plugin-import": "2.29.0",
"typescript": "5.3.2" "typescript": "5.3.2"
}, },

File diff suppressed because it is too large Load Diff

View File

@ -9,10 +9,12 @@ import cssnano from 'cssnano';
import postcss from 'postcss'; import postcss from 'postcss';
import * as terser from 'terser'; import * as terser from 'terser';
import locales from '../locales/index.js'; import { build as buildLocales } from '../locales/index.js';
import generateDTS from '../locales/generateDTS.js'; import generateDTS from '../locales/generateDTS.js';
import meta from '../package.json' assert { type: "json" }; import meta from '../package.json' assert { type: "json" };
let locales = buildLocales();
async function copyFrontendFonts() { async function copyFrontendFonts() {
await fs.cp('./packages/frontend/node_modules/three/examples/fonts', './built/_frontend_dist_/fonts', { dereference: true, recursive: true }); await fs.cp('./packages/frontend/node_modules/three/examples/fonts', './built/_frontend_dist_/fonts', { dereference: true, recursive: true });
} }
@ -89,10 +91,12 @@ async function build() {
await build(); await build();
if (process.argv.includes("--watch")) { if (process.argv.includes("--watch")) {
const watcher = fs.watch('./packages', { recursive: true }); const watcher = fs.watch('./locales');
for await (const event of watcher) { for await (const event of watcher) {
if (/^[a-z]+\/src/.test(event.filename)) { const filename = event.filename?.replaceAll('\\', '/');
await build(); if (/^[a-z]+-[A-Z]+\.yml/.test(filename)) {
locales = buildLocales();
await copyFrontendLocales()
} }
} }
} }