* enhance: pizzaxでstreamingのuser storage updateイベントを監視して更新 (#8095)

* wip

* wip?

* ?

* streamingのuser storage updateイベントを監視して更新

* 必要な時以外はストレージを更新しない

* fix?

* wip

* fix

* fix

* fix pizzax (#8099)

* Update CONTRIBUTING.md

* スコープの判定を厳密に (#8100)

* enhance(client): tweak ui

* enhance(client): tweak ui

* wip (#8101)

* Revert "revert d53795184"

This reverts commit aedbab17cc.

* fix

d53795184c (r62707827)

* update deps

* tweak client

* tweak ui

* lint

* refactor(server): use insert instead of save

* refactor(server): use insert instead of save

* tweak ui

* enhance: 許可されていないファイルタイプでは、オブジェクトストレージのファイル名に拡張子を付与しないように (#8108)

* 許可されていないファイルタイプでは、オブジェクトストレージのファイル名に拡張子を付与しないように

* add comment

* tweak ui

* tweak ui

* tweak ui

* clean up

* tweak ui

* tweak ui

* tweak ui

* tweak ui

* tweak ui

* tweak ui

* tweak ui

* tweak ui

* tweak ui

* 非ログイン時にエラーを吐くconsole.logを除去 (#8119)

* refactor(client): use composition api

* clean up

* refactor(client): use composition api

* refactor(client): use composition api

* refactor(client): use composition api

* refactor(client): use composition api

* refactor(client): use composition api

* clean up

* refactor(client): use composition api

* refactor(client): use composition api

* refactor(client): use composition api

* remove unused components

* bye room

* refactor: Widgetのcomposition api移行 (#8125)

* wip

* wip

* wip

* wip

* wip

* wip

* fix

* fix

* bye chat ui

* wip: migrate paging components to composition api

#7681

* wip: migrate paging components to composition api

* wip: migrate paging components to composition api

* wip: refactor(client): migrate paging components to composition api

* wip: refactor(client): migrate paging components to composition api

* fix

* refactor: Composition APIへ移行 (#8121)

* components/abuse-report-window.vue

* use <script setup>

* ✌️

* components/analog-clock.vue

* wip components/autocomplete.vue

* ✌️

* ✌️

* fix

* wip components/captcha.vue

* clean up

* components/channel-follow-button

* components/channel-preview.vue

* components/core-core.vue

* components/code.vue

* wip components/date-separated-list.vue

* fix

* fix autocomplete.vue

* ✌️

* remove global property

* use <script setup>

* components/dialog.vue

* clena up

* fix dialog.vue

* Resolve https://github.com/misskey-dev/misskey/pull/8121#discussion_r781250966

* fix

* bye reversi

* Fix The unauthenticated git protocol on port 9418 is no longer supported. (#8139)

* feat: multiple emojis editing

* feat: emojis import

* git add忘れ

* Update CHANGELOG.md

* clean up

* refactor

* wip: refactor(client): migrate paging components to composition api

* refactor

* wip: refactor(client): migrate paging components to composition api

* wip: refactor(client): migrate paging components to composition api

* wip: refactor(client): migrate paging components to composition api

* wip: refactor(client): migrate paging components to composition api

* 🎨

* wip: refactor(client): migrate paging components to composition api

* wip: refactor(client): migrate paging components to composition api

* wip: refactor(client): migrate paging components to composition api

* wip: refactor(client): migrate paging components to composition api

* wip: refactor(client): migrate components to composition api

* wip: refactor(client): migrate components to composition api

* wip: refactor(client): migrate components to composition api

* wip: refactor(client): migrate components to composition api

* wip: refactor(client): migrate components to composition api

* wip: refactor(client): migrate components to composition api

* wip: refactor(client): migrate components to composition api

* wip: refactor(client): migrate components to composition api

* wip: refactor(client): migrate components to composition api

* wip: refactor(client): migrate components to composition api

* wip: refactor(client): migrate components to composition api

* wip: refactor(client): migrate components to composition api

* clean up

* refactor(client): specify global scope

* refactor: disallow some variable names

* refactor: more common name

* wip: refactor(client): migrate components to composition api

* wip: refactor(client): migrate components to composition api

* wip: refactor(client): migrate components to composition api

* wip: refactor(client): migrate components to composition api

Fix #8155

* Fix #8151 (#8152)

* refactor

* refactor: APIエンドポイントファイルの定義を良い感じにする (#8154)

* Fix API Schema Error

* Delete SimpleSchema/SimpleObj
and Move schemas to dedicated files

* Userのスキーマを分割してみる

* define packMany type

* add ,

* Ensure enum schema and Make "as const" put once

* test?

* Revert "test?"

This reverts commit 97dc9bfa70851bfb7d1cf38e883f8df20fb78b79.

* Revert "Fix API Schema Error"

This reverts commit 21b6176d974ed8e3eb73723ad21a105c5d297323.

* ✌️

* clean up

* test?

* wip

* wip

* better schema def

* ✌️

* fix

* add minLength property

* wip

* wip

* wip

* anyOf/oneOf/allOfに対応? ~ relation.ts

* refactor!

* Define MinimumSchema

* wip

* wip

* anyOf/oneOf/allOfが動作するようにUnionSchemaTypeを修正

* anyOf/oneOf/allOfが動作するようにUnionSchemaTypeを修正

* Update packages/backend/src/misc/schema.ts

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>

* fix

* array oneOfをより正確な型に

* array oneOfをより正確な型に

* wip

* ✌️

* なんかもういろいろ

* remove

* very good schema

* api schema

* wip

* refactor: awaitAllの型定義を変えてみる

* fix

* specify types in awaitAll

* specify types in awaitAll

* ✌️

* wip

* ...

* ✌️

* AllowDateはやめておく

* 不必要なoptional: false, nullable: falseを廃止

* Packedが展開されないように

* 続packed

* wip

* define note type

* wip

* UserDetailedをMeDetailedかUserDetailedNotMeかを区別できるように

* wip

* wip

* wip specify user type of other schemas

* ok

* convertSchemaToOpenApiSchemaを改修

* convertSchemaToOpenApiSchemaを改修

* Fix

* fix

* ✌️

* wip

* 分割代入ではなくallOfで定義するように

Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>

* refactor: Composition APIへ移行 (#8138)

* components/drive-file-thumbnail.vue

* components/drive-select-dialog.vue

* components/drive-window.vue

* wip

* wip drive.file.vue, drive.vue

* fix prop

* wip(

* components/drive.folder.vue

* maybe ok

* ✌️

* fix variable

* FIX FOLDER VARIABLE

* components/emoji-picker-dialog.vue

* Hate `$emit`

* hate global property

* components/emoji-picker-window.vue

* components/emoji-picker.section.vue

* fix

* fixx

* wip components/emoji-picker.vue

* fix

* defineExpose

* ユニコード絵文字の型をもっといい感じに

* components/featured-photos.vue

* components/follow-button.vue

* forgot-password.vue

* forgot-password.vue

* 🎨

* fix

* モバイル画面で表示更新直後にヘッダーメニューをタップしてもポップアップにならないようにする (#8160)

* fix #8158

* refactor

* refactor

* refactor(server): use insert instead of save

* feat(server): store mime type of webpublic

* refactor(server): use named export

* fix: proxyでsvgをpngに変換するように (#8106)

* wip

* revert send-drive-file change

* fix

* Update packages/backend/src/server/proxy/proxy-media.ts

Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com>

Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com>

* send-drive-file svg as png (#8107)

* post-form.vue (#8164)

* feat(server): add more metadata for emoji export

* fix: code url in documentation (#8117)

It seems this was not changed while refactoring the modules apart.

* enhance: Forward report (#8001)

* implement sending AP Flag object

Optionally allow a user to select to forward a report about a remote
user to the other instance. This is added in a backwards-compatible way.

* add locale string

* forward report only for moderators

* add switch to moderator UI to forward report

* fix report note url

* return forwarded status from API

apparently forgot to carry this over from my testing environment

* object in Flag activity has to be an array

For correct interoperability with Pleroma the "object" property of the Flag
activity has to be an array.

This array will in the future also hold the link to respective notes, so it
makes sense to correct this on our side.

* Update get-note-menu.ts

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>

* enhance: e2eテストをできるだけ改良してみた (#8159)

* update docker image?

* 続

* serial run delete from "${table}" cascade

* use cypress official github action

* refuse install by cypress action

* clean up

* use wait?

* use more wait?

* Revert "use more wait?"

This reverts commit 18d0fcae9c7d8f98a4cafb4a846a031ece57350c.

* Revert "use wait?"

This reverts commit 5aa8feec9cdc3e2f79e566249f0a0eff6c0df6a0.

* fix

* test

* test

* log?

* 握りつぶしてみる

* clean up

* env?

* clean up?

* disable video

* add comment

* remove test

* 成功?

* test browser

* nodeインストール無効化

* node16.13.0-chrome95-ff94

* node.js復活

* ?

* ちょっと戻してみる

* chrome?

* cross browser test2

* --shm-size=2g

* artifact?

* misskey.local?

* firefoxはあきらめる

* not headless?

* oops

* fix

* ??

* test1

* if?

* fail-fast: false

* headless: false

* easy error ignoreing describe

* エラーの解消
とちょっとリファクター

* add browser name to artifact

* Install mplayer for FireFox

* no wait?

* タイムアウトを甘くしてみる

* firefoxをあきらめる(n回目)

* remove timeout setting

* wait復活

* Update basic.js

* Update index.js

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>

* update deps

* refactor

* fix(#8133): hCaptcha の reCAPTCHA 互換挙動を無効化する (#8135)

* fix(#8133): hCaptcha の reCAPTCHA 互換挙動を無効化する

* Update packages/client/src/components/captcha.vue

* fix: hCaptcha host

Co-authored-by: tamaina <tamaina@hotmail.co.jp>

* update local copy of file when describing (#8131)

* feat: increase files limit for note

#8062

* enhance: convert svg to png of custom emojis

* Update CHANGELOG.md

* update dep

* feat(client): make possible to switch account instantly in post form

* 投稿したらアカウントを元に戻すように

* chore(client): add tooltip

* wip: refactor(client): migrate components to composition api

* chore(client): add #misskey button

* fix(client): タイムラインのkeep-aliveが効かなくなっているのを修正

* NodeInfo にユーザー数と投稿数の情報を追加する (#8126)

* Unifying Misskey-specific IRIs in JSON-LD `@context` Resolve #8116  (#8178)

* Unifying Misskey-specific IRIs in JSON-LD `@context` Resolve #8116

* CHANGELOG

* refactor, enhance: ドライブ引数のオブジェクト化, 追加時のcomment指定 (#8180)

* refactor: ドライブの引数をオブジェクト化する Resolve #8177

* Resolve #8181

* fix

* archivePath

* fix: アップロードエラー時の処理を修正 (#8182)

* アップロードのエラー応答で詰むのを修正

* CHANGELOG

* fix: change keypress to keydown (#8192)

* Update docker-compose.yml (#8163)

Fix sometime es may cannot start
refer:https://m.html.cn/site/111215825993025.html

* disable animations on more transitions (#8112)

* 🎨

* Update CHANGELOG.md

* refactor(backend): use insert instead of save

* Update CONTRIBUTING.md

* enhance: Improve poll-editor UI + composition port (#8186)

* Poll editor UI changes

Use a horizontal layout when possible, wrap to vertical when constrained

* Port poll-editor to composition API

* Fix poll-editor `get` time calcs

* fix

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>

* refactor

* Update CONTRIBUTING.md

* 🎨

* Update extensions.json

* Fix pop-out bug (#8170)

* refactor: fix type

* refactor(backend): fix type

* refactor(backend): fix type

* New Crowdin updates (#8096)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (Japanese, Kansai)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Indonesian)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (Ukrainian)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Polish)

* New translations ja-JP.yml (Dutch)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (Czech)

* New translations ja-JP.yml (Arabic)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Czech)

* New translations ja-JP.yml (Czech)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (Japanese, Kansai)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Indonesian)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Chinese Traditional)

* New translations ja-JP.yml (Ukrainian)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Polish)

* New translations ja-JP.yml (Dutch)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (Czech)

* New translations ja-JP.yml (Arabic)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Russian)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Italian)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Bengali)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Esperanto)

* New translations ja-JP.yml (Arabic)

* New translations ja-JP.yml (Arabic)

* enhance: MediaListでは、サーバーで許可された形式しか表示しないように (#8113)

* wip

* fix

* update vue

* Update CHANGELOG.md

* 12.102.0

Co-authored-by: tamaina <tamaina@hotmail.co.jp>
Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
Co-authored-by: MeiMei <30769358+mei23@users.noreply.github.com>
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
Co-authored-by: xianon <xianon@hotmail.co.jp>
Co-authored-by: Johann150 <johann.galle@protonmail.com>
Co-authored-by: nullobsi <me@nullob.si>
Co-authored-by: Hyunseung Jeon <dogdriip@gmail.com>
Co-authored-by: 老兄 <lao__xong@outlook.com>
Co-authored-by: Derek <skeh@is.nota.live>
Co-authored-by: Kainoa Kanter <44733677+ThatOneCalculator@users.noreply.github.com>
This commit is contained in:
NullCat
2022-01-27 00:50:28 +09:00
committed by GitHub
parent af6d52e4c8
commit 0fb9a438f9
955 changed files with 16618 additions and 34411 deletions

View File

@ -16,6 +16,8 @@ const data = localStorage.getItem('account');
// TODO: 外部からはreadonlyに
export const $i = data ? reactive(JSON.parse(data) as Account) : null;
export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator);
export async function signout() {
waiting();
localStorage.removeItem('account');
@ -127,7 +129,12 @@ export async function login(token: Account['token'], redirect?: string) {
unisonReload();
}
export async function openAccountMenu(ev: MouseEvent) {
export async function openAccountMenu(opts: {
includeCurrentAccount?: boolean;
withExtraOperation: boolean;
active?: misskey.entities.UserDetailed['id'];
onChoose?: (account: misskey.entities.UserDetailed) => void;
}, ev: MouseEvent) {
function showSigninDialog() {
popup(import('@/components/signin-dialog.vue'), {}, {
done: res => {
@ -146,7 +153,7 @@ export async function openAccountMenu(ev: MouseEvent) {
}, 'closed');
}
async function switchAccount(account: any) {
async function switchAccount(account: misskey.entities.UserDetailed) {
const storedAccounts = await getAccounts();
const token = storedAccounts.find(x => x.id === account.id).token;
switchAccountWithToken(token);
@ -159,48 +166,58 @@ export async function openAccountMenu(ev: MouseEvent) {
const storedAccounts = await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id));
const accountsPromise = api('users/show', { userIds: storedAccounts.map(x => x.id) });
function createItem(account: misskey.entities.UserDetailed) {
return {
type: 'user',
user: account,
active: opts.active != null ? opts.active === account.id : false,
action: () => {
if (opts.onChoose) {
opts.onChoose(account);
} else {
switchAccount(account);
}
},
};
}
const accountItemPromises = storedAccounts.map(a => new Promise(res => {
accountsPromise.then(accounts => {
const account = accounts.find(x => x.id === a.id);
if (account == null) return res(null);
res({
type: 'user',
user: account,
action: () => { switchAccount(account); }
});
res(createItem(account));
});
}));
popupMenu([...[{
type: 'link',
text: i18n.locale.profile,
to: `/@${ $i.username }`,
avatar: $i,
}, null, ...accountItemPromises, {
icon: 'fas fa-plus',
text: i18n.locale.addAccount,
action: () => {
popupMenu([{
text: i18n.locale.existingAccount,
action: () => { showSigninDialog(); },
}, {
text: i18n.locale.createAccount,
action: () => { createAccount(); },
}], ev.currentTarget || ev.target);
},
}, {
type: 'link',
icon: 'fas fa-users',
text: i18n.locale.manageAccounts,
to: `/settings/accounts`,
}]], ev.currentTarget || ev.target, {
align: 'left'
});
}
// このファイルに書きたくないけどここに書かないと何故かVeturが認識しない
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$i: typeof $i;
if (opts.withExtraOperation) {
popupMenu([...[{
type: 'link',
text: i18n.locale.profile,
to: `/@${ $i.username }`,
avatar: $i,
}, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
icon: 'fas fa-plus',
text: i18n.locale.addAccount,
action: () => {
popupMenu([{
text: i18n.locale.existingAccount,
action: () => { showSigninDialog(); },
}, {
text: i18n.locale.createAccount,
action: () => { createAccount(); },
}], ev.currentTarget || ev.target);
},
}, {
type: 'link',
icon: 'fas fa-users',
text: i18n.locale.manageAccounts,
to: `/settings/accounts`,
}]], ev.currentTarget || ev.target, {
align: 'left'
});
} else {
popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget || ev.target, {
align: 'left'
});
}
}

View File

@ -10,13 +10,13 @@
<XCwButton v-model="showContent" :note="note"/>
</p>
<div v-show="note.cw == null || showContent" class="content">
<XSubNote-content class="text" :note="note"/>
<MkNoteSubNoteContent class="text" :note="note"/>
</div>
</div>
</div>
</div>
<template v-if="depth < 5">
<XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :depth="depth + 1"/>
<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :depth="depth + 1"/>
</template>
<div v-else class="more">
<MkA class="text _link" :to="notePage(note)">{{ $ts.continueThread }} <i class="fas fa-angle-double-right"></i></MkA>
@ -24,63 +24,36 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
import * as misskey from 'misskey-js';
import { notePage } from '@/filters/note';
import XNoteHeader from './note-header.vue';
import XSubNoteContent from './sub-note-content.vue';
import MkNoteSubNoteContent from './sub-note-content.vue';
import XCwButton from './cw-button.vue';
import * as os from '@/os';
export default defineComponent({
name: 'XSub',
const props = withDefaults(defineProps<{
note: misskey.entities.Note;
detail?: boolean;
components: {
XNoteHeader,
XSubNoteContent,
XCwButton,
},
props: {
note: {
type: Object,
required: true
},
detail: {
type: Boolean,
required: false,
default: false
},
// how many notes are in between this one and the note being viewed in detail
depth: {
type: Number,
required: false,
default: 1
},
},
data() {
return {
showContent: false,
replies: [],
};
},
created() {
if (this.detail) {
os.api('notes/children', {
noteId: this.note.id,
limit: 5
}).then(replies => {
this.replies = replies;
});
}
},
methods: {
notePage,
}
// how many notes are in between this one and the note being viewed in detail
depth?: number;
}>(), {
depth: 1,
});
let showContent = $ref(false);
let replies: misskey.entities.Note[] = $ref([]);
if (props.detail) {
os.api('notes/children', {
noteId: props.note.id,
limit: 5
}).then(res => {
replies = res;
});
}
</script>
<style lang="scss" scoped>

View File

@ -1,8 +1,8 @@
<template>
<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="$emit('closed')">
<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')">
<template #header>
<i class="fas fa-exclamation-circle" style="margin-right: 0.5em;"></i>
<I18n :src="$ts.reportAbuseOf" tag="span">
<I18n :src="i18n.locale.reportAbuseOf" tag="span">
<template #name>
<b><MkAcct :user="user"/></b>
</template>
@ -11,65 +11,51 @@
<div class="dpvffvvy _monolithic_">
<div class="_section">
<MkTextarea v-model="comment">
<template #label>{{ $ts.details }}</template>
<template #caption>{{ $ts.fillAbuseReportDescription }}</template>
<template #label>{{ i18n.locale.details }}</template>
<template #caption>{{ i18n.locale.fillAbuseReportDescription }}</template>
</MkTextarea>
</div>
<div class="_section">
<MkButton primary full :disabled="comment.length === 0" @click="send">{{ $ts.send }}</MkButton>
<MkButton primary full :disabled="comment.length === 0" @click="send">{{ i18n.locale.send }}</MkButton>
</div>
</div>
</XWindow>
</template>
<script lang="ts">
import { defineComponent, markRaw } from 'vue';
<script setup lang="ts">
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import XWindow from '@/components/ui/window.vue';
import MkTextarea from '@/components/form/textarea.vue';
import MkButton from '@/components/ui/button.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
export default defineComponent({
components: {
XWindow,
MkTextarea,
MkButton,
},
const props = defineProps<{
user: Misskey.entities.User;
initialComment?: string;
}>();
props: {
user: {
type: Object,
required: true,
},
initialComment: {
type: String,
required: false,
},
},
const emit = defineEmits<{
(e: 'closed'): void;
}>();
emits: ['closed'],
const window = ref<InstanceType<typeof XWindow>>();
const comment = ref(props.initialComment || '');
data() {
return {
comment: this.initialComment || '',
};
},
methods: {
send() {
os.apiWithDialog('users/report-abuse', {
userId: this.user.id,
comment: this.comment,
}, undefined, res => {
os.alert({
type: 'success',
text: this.$ts.abuseReported
});
this.$refs.window.close();
});
}
},
});
function send() {
os.apiWithDialog('users/report-abuse', {
userId: props.user.id,
comment: comment.value,
}, undefined).then(res => {
os.alert({
type: 'success',
text: i18n.locale.abuseReported
});
window.value?.close();
emit('closed');
});
}
</script>
<style lang="scss" scoped>

View File

@ -0,0 +1,102 @@
<template>
<div class="bcekxzvu _card _gap">
<div class="_content target">
<MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true"/>
<MkA class="info" :to="userPage(report.targetUser)" v-user-preview="report.targetUserId">
<MkUserName class="name" :user="report.targetUser"/>
<MkAcct class="acct" :user="report.targetUser" style="display: block;"/>
</MkA>
</div>
<div class="_content">
<div>
<Mfm :text="report.comment"/>
</div>
<hr/>
<div>{{ $ts.reporter }}: <MkAcct :user="report.reporter"/></div>
<div v-if="report.assignee">
{{ $ts.moderator }}:
<MkAcct :user="report.assignee"/>
</div>
<div><MkTime :time="report.createdAt"/></div>
</div>
<div class="_footer">
<MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved">
{{ $ts.forwardReport }}
<template #caption>{{ $ts.forwardReportIsAnonymous }}</template>
</MkSwitch>
<MkButton v-if="!report.resolved" primary @click="resolve">{{ $ts.abuseMarkAsResolved }}</MkButton>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import MkButton from '@/components/ui/button.vue';
import MkSwitch from '@/components/form/switch.vue';
import { acct, userPage } from '@/filters/user';
import * as os from '@/os';
export default defineComponent({
components: {
MkButton,
MkSwitch,
},
emits: ['resolved'],
props: {
report: {
type: Object,
required: true,
}
}
data() {
return {
forward: this.report.forwarded,
};
}
methods: {
acct,
userPage,
resolve() {
os.apiWithDialog('admin/resolve-abuse-user-report', {
forward: this.forward,
reportId: this.report.id,
}).then(() => {
this.$emit('resolved', this.report.id);
});
}
}
});
</script>
<style lang="scss" scoped>
.bcekxzvu {
> .target {
display: flex;
width: 100%;
box-sizing: border-box;
text-align: left;
align-items: center;
> .avatar {
width: 42px;
height: 42px;
}
> .info {
margin-left: 0.3em;
padding: 0 8px;
flex: 1;
> .name {
font-weight: bold;
}
}
}
}
</style>

View File

@ -40,106 +40,64 @@
</svg>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import * as tinycolor from 'tinycolor2';
export default defineComponent({
props: {
thickness: {
type: Number,
default: 0.1
}
},
withDefaults(defineProps<{
thickness: number;
}>(), {
thickness: 0.1,
});
data() {
return {
now: new Date(),
enabled: true,
const now = ref(new Date());
const enabled = ref(true);
const graduationsPadding = ref(0.5);
const handsPadding = ref(1);
const handsTailLength = ref(0.7);
const hHandLengthRatio = ref(0.75);
const mHandLengthRatio = ref(1);
const sHandLengthRatio = ref(1);
const computedStyle = getComputedStyle(document.documentElement);
graduationsPadding: 0.5,
handsPadding: 1,
handsTailLength: 0.7,
hHandLengthRatio: 0.75,
mHandLengthRatio: 1,
sHandLengthRatio: 1,
computedStyle: getComputedStyle(document.documentElement)
};
},
computed: {
dark(): boolean {
return tinycolor(this.computedStyle.getPropertyValue('--bg')).isDark();
},
majorGraduationColor(): string {
return this.dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)';
},
minorGraduationColor(): string {
return this.dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
},
sHandColor(): string {
return this.dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)';
},
mHandColor(): string {
return tinycolor(this.computedStyle.getPropertyValue('--fg')).toHexString();
},
hHandColor(): string {
return tinycolor(this.computedStyle.getPropertyValue('--accent')).toHexString();
},
s(): number {
return this.now.getSeconds();
},
m(): number {
return this.now.getMinutes();
},
h(): number {
return this.now.getHours();
},
hAngle(): number {
return Math.PI * (this.h % 12 + (this.m + this.s / 60) / 60) / 6;
},
mAngle(): number {
return Math.PI * (this.m + this.s / 60) / 30;
},
sAngle(): number {
return Math.PI * this.s / 30;
},
graduations(): any {
const angles = [];
for (let i = 0; i < 60; i++) {
const angle = Math.PI * i / 30;
angles.push(angle);
}
return angles;
}
},
mounted() {
const update = () => {
if (this.enabled) {
this.tick();
setTimeout(update, 1000);
}
};
update();
},
beforeUnmount() {
this.enabled = false;
},
methods: {
tick() {
this.now = new Date();
}
const dark = computed(() => tinycolor(computedStyle.getPropertyValue('--bg')).isDark());
const majorGraduationColor = computed(() => dark.value ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)');
const minorGraduationColor = computed(() => dark.value ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)');
const sHandColor = computed(() => dark.value ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)');
const mHandColor = computed(() => tinycolor(computedStyle.getPropertyValue('--fg')).toHexString());
const hHandColor = computed(() => tinycolor(computedStyle.getPropertyValue('--accent')).toHexString());
const s = computed(() => now.value.getSeconds());
const m = computed(() => now.value.getMinutes());
const h = computed(() => now.value.getHours());
const hAngle = computed(() => Math.PI * (h.value % 12 + (m.value + s.value / 60) / 60) / 6);
const mAngle = computed(() => Math.PI * (m.value + s.value / 60) / 30);
const sAngle = computed(() => Math.PI * s.value / 30);
const graduations = computed(() => {
const angles: number[] = [];
for (let i = 0; i < 60; i++) {
const angle = Math.PI * i / 30;
angles.push(angle);
}
return angles;
});
function tick() {
now.value = new Date();
}
onMounted(() => {
const update = () => {
if (enabled.value) {
tick();
window.setTimeout(update, 1000);
}
};
update();
});
onBeforeUnmount(() => {
enabled.value = false;
});
</script>

View File

@ -1,5 +1,5 @@
<template>
<div class="swhvrteh _popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}">
<div ref="rootEl" class="swhvrteh _popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}">
<ol v-if="type === 'user'" ref="suggests" class="users">
<li v-for="user in users" tabindex="-1" class="user" @click="complete(type, user)" @keydown="onKeydown">
<img class="avatar" :src="user.avatarUrl"/>
@ -8,7 +8,7 @@
</span>
<span class="username">@{{ acct(user) }}</span>
</li>
<li tabindex="-1" class="choose" @click="chooseUser()" @keydown="onKeydown">{{ $ts.selectUser }}</li>
<li tabindex="-1" class="choose" @click="chooseUser()" @keydown="onKeydown">{{ i18n.locale.selectUser }}</li>
</ol>
<ol v-else-if="hashtags.length > 0" ref="suggests" class="hashtags">
<li v-for="hashtag in hashtags" tabindex="-1" @click="complete(type, hashtag)" @keydown="onKeydown">
@ -17,8 +17,8 @@
</ol>
<ol v-else-if="emojis.length > 0" ref="suggests" class="emojis">
<li v-for="emoji in emojis" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown">
<span v-if="emoji.isCustomEmoji" class="emoji"><img :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span>
<span v-else-if="!$store.state.useOsNativeEmojis" class="emoji"><img :src="emoji.url" :alt="emoji.emoji"/></span>
<span v-if="emoji.isCustomEmoji" class="emoji"><img :src="defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span>
<span v-else-if="!defaultStore.state.useOsNativeEmojis" class="emoji"><img :src="emoji.url" :alt="emoji.emoji"/></span>
<span v-else class="emoji">{{ emoji.emoji }}</span>
<span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span>
<span v-if="emoji.aliasOf" class="alias">({{ emoji.aliasOf }})</span>
@ -33,15 +33,17 @@
</template>
<script lang="ts">
import { defineComponent, markRaw } from 'vue';
import { emojilist } from '@/scripts/emojilist';
import { markRaw, ref, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import contains from '@/scripts/contains';
import { twemojiSvgBase } from '@/scripts/twemoji-base';
import { getStaticImageUrl } from '@/scripts/get-static-image-url';
import { acct } from '@/filters/user';
import * as os from '@/os';
import { instance } from '@/instance';
import { MFM_TAGS } from '@/scripts/mfm-tags';
import { defaultStore } from '@/store';
import { emojilist } from '@/scripts/emojilist';
import { instance } from '@/instance';
import { twemojiSvgBase } from '@/scripts/twemoji-base';
import { i18n } from '@/i18n';
type EmojiDef = {
emoji: string;
@ -54,16 +56,14 @@ type EmojiDef = {
const lib = emojilist.filter(x => x.category !== 'flags');
const char2file = (char: string) => {
let codes = Array.from(char).map(x => x.codePointAt(0).toString(16));
let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16));
if (!codes.includes('200d')) codes = codes.filter(x => x != 'fe0f');
codes = codes.filter(x => x && x.length);
return codes.join('-');
return codes.filter(x => x && x.length).join('-');
};
const emjdb: EmojiDef[] = lib.map(x => ({
emoji: x.char,
name: x.name,
aliasOf: null,
url: `${twemojiSvgBase}/${char2file(x.char)}.svg`
}));
@ -112,291 +112,270 @@ emojiDefinitions.sort((a, b) => a.name.length - b.name.length);
const emojiDb = markRaw(emojiDefinitions.concat(emjdb));
//#endregion
export default defineComponent({
props: {
type: {
type: String,
required: true,
},
export default {
emojiDb,
emojiDefinitions,
emojilist,
customEmojis,
};
</script>
q: {
type: String,
required: false,
},
<script lang="ts" setup>
const props = defineProps<{
type: string;
q: string | null;
textarea: HTMLTextAreaElement;
close: () => void;
x: number;
y: number;
}>();
textarea: {
type: HTMLTextAreaElement,
required: true,
},
const emit = defineEmits<{
(e: 'done', v: { type: string; value: any }): void;
(e: 'closed'): void;
}>();
close: {
type: Function,
required: true,
},
const suggests = ref<Element>();
const rootEl = ref<HTMLDivElement>();
x: {
type: Number,
required: true,
},
const fetching = ref(true);
const users = ref<any[]>([]);
const hashtags = ref<any[]>([]);
const emojis = ref<(EmojiDef)[]>([]);
const items = ref<Element[] | HTMLCollection>([]);
const mfmTags = ref<string[]>([]);
const select = ref(-1);
const zIndex = os.claimZIndex('high');
y: {
type: Number,
required: true,
},
},
function complete(type: string, value: any) {
emit('done', { type, value });
emit('closed');
if (type === 'emoji') {
let recents = defaultStore.state.recentlyUsedEmojis;
recents = recents.filter((e: any) => e !== value);
recents.unshift(value);
defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32));
}
}
emits: ['done', 'closed'],
function setPosition() {
if (!rootEl.value) return;
if (props.x + rootEl.value.offsetWidth > window.innerWidth) {
rootEl.value.style.left = (window.innerWidth - rootEl.value.offsetWidth) + 'px';
} else {
rootEl.value.style.left = `${props.x}px`;
}
if (props.y + rootEl.value.offsetHeight > window.innerHeight) {
rootEl.value.style.top = (props.y - rootEl.value.offsetHeight) + 'px';
rootEl.value.style.marginTop = '0';
} else {
rootEl.value.style.top = props.y + 'px';
rootEl.value.style.marginTop = 'calc(1em + 8px)';
}
}
data() {
return {
getStaticImageUrl,
fetching: true,
users: [],
hashtags: [],
emojis: [],
items: [],
mfmTags: [],
select: -1,
zIndex: os.claimZIndex('high'),
function exec() {
select.value = -1;
if (suggests.value) {
for (const el of Array.from(items.value)) {
el.removeAttribute('data-selected');
}
},
updated() {
this.setPosition();
this.items = (this.$refs.suggests as Element | undefined)?.children || [];
},
mounted() {
this.setPosition();
this.textarea.addEventListener('keydown', this.onKeydown);
for (const el of Array.from(document.querySelectorAll('body *'))) {
el.addEventListener('mousedown', this.onMousedown);
}
if (props.type === 'user') {
if (!props.q) {
users.value = [];
fetching.value = false;
return;
}
this.$nextTick(() => {
this.exec();
const cacheKey = `autocomplete:user:${props.q}`;
const cache = sessionStorage.getItem(cacheKey);
this.$watch('q', () => {
this.$nextTick(() => {
this.exec();
if (cache) {
const users = JSON.parse(cache);
users.value = users;
fetching.value = false;
} else {
os.api('users/search-by-username-and-host', {
username: props.q,
limit: 10,
detail: false
}).then(searchedUsers => {
users.value = searchedUsers as any[];
fetching.value = false;
// キャッシュ
sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
});
}
} else if (props.type === 'hashtag') {
if (!props.q || props.q == '') {
hashtags.value = JSON.parse(localStorage.getItem('hashtags') || '[]');
fetching.value = false;
} else {
const cacheKey = `autocomplete:hashtag:${props.q}`;
const cache = sessionStorage.getItem(cacheKey);
if (cache) {
const hashtags = JSON.parse(cache);
hashtags.value = hashtags;
fetching.value = false;
} else {
os.api('hashtags/search', {
query: props.q,
limit: 30
}).then(searchedHashtags => {
hashtags.value = searchedHashtags as any[];
fetching.value = false;
// キャッシュ
sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags));
});
}
}
} else if (props.type === 'emoji') {
if (!props.q || props.q == '') {
// 最近使った絵文字をサジェスト
emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.find(e => e.emoji == emoji)).filter(x => x) as EmojiDef[];
return;
}
const matched: EmojiDef[] = [];
const max = 30;
emojiDb.some(x => {
if (x.name.startsWith(props.q || '') && !x.aliasOf && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
return matched.length == max;
});
if (matched.length < max) {
emojiDb.some(x => {
if (x.name.startsWith(props.q || '') && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
return matched.length == max;
});
}
if (matched.length < max) {
emojiDb.some(x => {
if (x.name.includes(props.q || '') && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
return matched.length == max;
});
}
emojis.value = matched;
} else if (props.type === 'mfmTag') {
if (!props.q || props.q == '') {
mfmTags.value = MFM_TAGS;
return;
}
mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q || ''));
}
}
function onMousedown(e: Event) {
if (!contains(rootEl.value, e.target) && (rootEl.value != e.target)) props.close();
}
function onKeydown(e: KeyboardEvent) {
const cancel = () => {
e.preventDefault();
e.stopPropagation();
};
switch (e.key) {
case 'Enter':
if (select.value !== -1) {
cancel();
(items.value[select.value] as any).click();
} else {
props.close();
}
break;
case 'Escape':
cancel();
props.close();
break;
case 'ArrowUp':
if (select.value !== -1) {
cancel();
selectPrev();
} else {
props.close();
}
break;
case 'Tab':
case 'ArrowDown':
cancel();
selectNext();
break;
default:
e.stopPropagation();
props.textarea.focus();
}
}
function selectNext() {
if (++select.value >= items.value.length) select.value = 0;
if (items.value.length === 0) select.value = -1;
applySelect();
}
function selectPrev() {
if (--select.value < 0) select.value = items.value.length - 1;
applySelect();
}
function applySelect() {
for (const el of Array.from(items.value)) {
el.removeAttribute('data-selected');
}
if (select.value !== -1) {
items.value[select.value].setAttribute('data-selected', 'true');
(items.value[select.value] as any).focus();
}
}
function chooseUser() {
props.close();
os.selectUser().then(user => {
complete('user', user);
props.textarea.focus();
});
}
onUpdated(() => {
setPosition();
items.value = suggests.value?.children || [];
});
onMounted(() => {
setPosition();
props.textarea.addEventListener('keydown', onKeydown);
for (const el of Array.from(document.querySelectorAll('body *'))) {
el.addEventListener('mousedown', onMousedown);
}
nextTick(() => {
exec();
watch(() => props.q, () => {
nextTick(() => {
exec();
});
});
},
});
});
beforeUnmount() {
this.textarea.removeEventListener('keydown', this.onKeydown);
onBeforeUnmount(() => {
props.textarea.removeEventListener('keydown', onKeydown);
for (const el of Array.from(document.querySelectorAll('body *'))) {
el.removeEventListener('mousedown', this.onMousedown);
}
},
methods: {
complete(type, value) {
this.$emit('done', { type, value });
this.$emit('closed');
if (type === 'emoji') {
let recents = this.$store.state.recentlyUsedEmojis;
recents = recents.filter((e: any) => e !== value);
recents.unshift(value);
this.$store.set('recentlyUsedEmojis', recents.splice(0, 32));
}
},
setPosition() {
if (this.x + this.$el.offsetWidth > window.innerWidth) {
this.$el.style.left = (window.innerWidth - this.$el.offsetWidth) + 'px';
} else {
this.$el.style.left = this.x + 'px';
}
if (this.y + this.$el.offsetHeight > window.innerHeight) {
this.$el.style.top = (this.y - this.$el.offsetHeight) + 'px';
this.$el.style.marginTop = '0';
} else {
this.$el.style.top = this.y + 'px';
this.$el.style.marginTop = 'calc(1em + 8px)';
}
},
exec() {
this.select = -1;
if (this.$refs.suggests) {
for (const el of Array.from(this.items)) {
el.removeAttribute('data-selected');
}
}
if (this.type === 'user') {
if (this.q == null) {
this.users = [];
this.fetching = false;
return;
}
const cacheKey = `autocomplete:user:${this.q}`;
const cache = sessionStorage.getItem(cacheKey);
if (cache) {
const users = JSON.parse(cache);
this.users = users;
this.fetching = false;
} else {
os.api('users/search-by-username-and-host', {
username: this.q,
limit: 10,
detail: false
}).then(users => {
this.users = users;
this.fetching = false;
// キャッシュ
sessionStorage.setItem(cacheKey, JSON.stringify(users));
});
}
} else if (this.type === 'hashtag') {
if (this.q == null || this.q == '') {
this.hashtags = JSON.parse(localStorage.getItem('hashtags') || '[]');
this.fetching = false;
} else {
const cacheKey = `autocomplete:hashtag:${this.q}`;
const cache = sessionStorage.getItem(cacheKey);
if (cache) {
const hashtags = JSON.parse(cache);
this.hashtags = hashtags;
this.fetching = false;
} else {
os.api('hashtags/search', {
query: this.q,
limit: 30
}).then(hashtags => {
this.hashtags = hashtags;
this.fetching = false;
// キャッシュ
sessionStorage.setItem(cacheKey, JSON.stringify(hashtags));
});
}
}
} else if (this.type === 'emoji') {
if (this.q == null || this.q == '') {
// 最近使った絵文字をサジェスト
this.emojis = this.$store.state.recentlyUsedEmojis.map(emoji => emojiDb.find(e => e.emoji == emoji)).filter(x => x != null);
return;
}
const matched = [];
const max = 30;
emojiDb.some(x => {
if (x.name.startsWith(this.q) && !x.aliasOf && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
return matched.length == max;
});
if (matched.length < max) {
emojiDb.some(x => {
if (x.name.startsWith(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
return matched.length == max;
});
}
if (matched.length < max) {
emojiDb.some(x => {
if (x.name.includes(this.q) && !matched.some(y => y.emoji == x.emoji)) matched.push(x);
return matched.length == max;
});
}
this.emojis = matched;
} else if (this.type === 'mfmTag') {
if (this.q == null || this.q == '') {
this.mfmTags = MFM_TAGS;
return;
}
this.mfmTags = MFM_TAGS.filter(tag => tag.startsWith(this.q));
}
},
onMousedown(e) {
if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close();
},
onKeydown(e) {
const cancel = () => {
e.preventDefault();
e.stopPropagation();
};
switch (e.which) {
case 10: // [ENTER]
case 13: // [ENTER]
if (this.select !== -1) {
cancel();
(this.items[this.select] as any).click();
} else {
this.close();
}
break;
case 27: // [ESC]
cancel();
this.close();
break;
case 38: // [↑]
if (this.select !== -1) {
cancel();
this.selectPrev();
} else {
this.close();
}
break;
case 9: // [TAB]
case 40: // [↓]
cancel();
this.selectNext();
break;
default:
e.stopPropagation();
this.textarea.focus();
}
},
selectNext() {
if (++this.select >= this.items.length) this.select = 0;
if (this.items.length === 0) this.select = -1;
this.applySelect();
},
selectPrev() {
if (--this.select < 0) this.select = this.items.length - 1;
this.applySelect();
},
applySelect() {
for (const el of Array.from(this.items)) {
el.removeAttribute('data-selected');
}
if (this.select !== -1) {
this.items[this.select].setAttribute('data-selected', 'true');
(this.items[this.select] as any).focus();
}
},
chooseUser() {
this.close();
os.selectUser().then(user => {
this.complete('user', user);
this.textarea.focus();
});
},
acct
for (const el of Array.from(document.querySelectorAll('body *'))) {
el.removeEventListener('mousedown', onMousedown);
}
});
</script>

View File

@ -1,30 +1,24 @@
<template>
<div>
<div v-for="user in us" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;">
<div v-for="user in users" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;">
<MkAvatar :user="user" style="width:32px;height:32px;" :show-indicator="true"/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import * as os from '@/os';
export default defineComponent({
props: {
userIds: {
required: true
},
},
data() {
return {
us: []
};
},
async created() {
this.us = await os.api('users/show', {
userIds: this.userIds
});
}
const props = defineProps<{
userIds: string[];
}>();
const users = ref([]);
onMounted(async () => {
users.value = await os.api('users/show', {
userIds: props.userIds
});
});
</script>

View File

@ -1,12 +1,14 @@
<template>
<div>
<span v-if="!available">{{ $ts.waiting }}<MkEllipsis/></span>
<div ref="captcha"></div>
<span v-if="!available">{{ i18n.locale.waiting }}<MkEllipsis/></span>
<div ref="captchaEl"></div>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
<script lang="ts" setup>
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
type Captcha = {
render(container: string | Node, options: {
@ -14,7 +16,7 @@ type Captcha = {
}): string;
remove(id: string): void;
execute(id: string): void;
reset(id: string): void;
reset(id?: string): void;
getResponse(id: string): string;
};
@ -29,95 +31,85 @@ declare global {
}
}
export default defineComponent({
props: {
provider: {
type: String as PropType<CaptchaProvider>,
required: true,
},
sitekey: {
type: String,
required: true,
},
modelValue: {
type: String,
},
},
const props = defineProps<{
provider: CaptchaProvider;
sitekey: string;
modelValue?: string | null;
}>();
data() {
return {
available: false,
};
},
const emit = defineEmits<{
(ev: 'update:modelValue', v: string | null): void;
}>();
computed: {
variable(): string {
switch (this.provider) {
case 'hcaptcha': return 'hcaptcha';
case 'recaptcha': return 'grecaptcha';
}
},
loaded(): boolean {
return !!window[this.variable];
},
src(): string {
const endpoint = ({
hcaptcha: 'https://hcaptcha.com/1',
recaptcha: 'https://www.recaptcha.net/recaptcha',
} as Record<CaptchaProvider, string>)[this.provider];
const available = ref(false);
return `${typeof endpoint === 'string' ? endpoint : 'about:invalid'}/api.js?render=explicit`;
},
captcha(): Captcha {
return window[this.variable] || {} as unknown as Captcha;
},
},
const captchaEl = ref<HTMLDivElement | undefined>();
created() {
if (this.loaded) {
this.available = true;
} else {
(document.getElementById(this.provider) || document.head.appendChild(Object.assign(document.createElement('script'), {
async: true,
id: this.provider,
src: this.src,
})))
.addEventListener('load', () => this.available = true);
}
},
mounted() {
if (this.available) {
this.requestRender();
} else {
this.$watch('available', this.requestRender);
}
},
beforeUnmount() {
this.reset();
},
methods: {
reset() {
if (this.captcha?.reset) this.captcha.reset();
},
requestRender() {
if (this.captcha.render && this.$refs.captcha instanceof Element) {
this.captcha.render(this.$refs.captcha, {
sitekey: this.sitekey,
theme: this.$store.state.darkMode ? 'dark' : 'light',
callback: this.callback,
'expired-callback': this.callback,
'error-callback': this.callback,
});
} else {
setTimeout(this.requestRender.bind(this), 1);
}
},
callback(response?: string) {
this.$emit('update:modelValue', typeof response == 'string' ? response : null);
},
},
const variable = computed(() => {
switch (props.provider) {
case 'hcaptcha': return 'hcaptcha';
case 'recaptcha': return 'grecaptcha';
}
});
const loaded = computed(() => !!window[variable.value]);
const src = computed(() => {
switch (props.provider) {
case 'hcaptcha': return 'https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off';
case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit';
}
});
const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
if (loaded.value) {
available.value = true;
} else {
(document.getElementById(props.provider) || document.head.appendChild(Object.assign(document.createElement('script'), {
async: true,
id: props.provider,
src: src.value,
})))
.addEventListener('load', () => available.value = true);
}
function reset() {
if (captcha.value?.reset) captcha.value.reset();
}
function requestRender() {
if (captcha.value.render && captchaEl.value instanceof Element) {
captcha.value.render(captchaEl.value, {
sitekey: props.sitekey,
theme: defaultStore.state.darkMode ? 'dark' : 'light',
callback: callback,
'expired-callback': callback,
'error-callback': callback,
});
} else {
window.setTimeout(requestRender, 1);
}
}
function callback(response?: string) {
emit('update:modelValue', typeof response == 'string' ? response : null);
}
onMounted(() => {
if (available.value) {
requestRender();
} else {
watch(available, requestRender);
}
});
onBeforeUnmount(() => {
reset();
});
defineExpose({
reset,
});
</script>

View File

@ -6,66 +6,54 @@
>
<template v-if="!wait">
<template v-if="isFollowing">
<span v-if="full">{{ $ts.unfollow }}</span><i class="fas fa-minus"></i>
<span v-if="full">{{ i18n.locale.unfollow }}</span><i class="fas fa-minus"></i>
</template>
<template v-else>
<span v-if="full">{{ $ts.follow }}</span><i class="fas fa-plus"></i>
<span v-if="full">{{ i18n.locale.follow }}</span><i class="fas fa-plus"></i>
</template>
</template>
<template v-else>
<span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i>
<span v-if="full">{{ i18n.locale.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i>
</template>
</button>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { ref } from 'vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
export default defineComponent({
props: {
channel: {
type: Object,
required: true
},
full: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
isFollowing: this.channel.isFollowing,
wait: false,
};
},
methods: {
async onClick() {
this.wait = true;
try {
if (this.isFollowing) {
await os.api('channels/unfollow', {
channelId: this.channel.id
});
this.isFollowing = false;
} else {
await os.api('channels/follow', {
channelId: this.channel.id
});
this.isFollowing = true;
}
} catch (e) {
console.error(e);
} finally {
this.wait = false;
}
}
}
const props = withDefaults(defineProps<{
channel: Record<string, any>;
full?: boolean;
}>(), {
full: false,
});
const isFollowing = ref<boolean>(props.channel.isFollowing);
const wait = ref(false);
async function onClick() {
wait.value = true;
try {
if (isFollowing.value) {
await os.api('channels/unfollow', {
channelId: props.channel.id
});
isFollowing.value = false;
} else {
await os.api('channels/follow', {
channelId: props.channel.id
});
isFollowing.value = true;
}
} catch (e) {
console.error(e);
} finally {
wait.value = false;
}
}
</script>
<style lang="scss" scoped>

View File

@ -6,7 +6,7 @@
<div class="status">
<div>
<i class="fas fa-users fa-fw"></i>
<I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;">
<I18n :src="i18n.locale._channel.usersCount" tag="span" style="margin-left: 4px;">
<template #n>
<b>{{ channel.usersCount }}</b>
</template>
@ -14,7 +14,7 @@
</div>
<div>
<i class="fas fa-pencil-alt fa-fw"></i>
<I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;">
<I18n :src="i18n.locale._channel.notesCount" tag="span" style="margin-left: 4px;">
<template #n>
<b>{{ channel.notesCount }}</b>
</template>
@ -27,37 +27,26 @@
</article>
<footer>
<span v-if="channel.lastNotedAt">
{{ $ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/>
{{ i18n.locale.updatedAt }}: <MkTime :time="channel.lastNotedAt"/>
</span>
</footer>
</MkA>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { computed } from 'vue';
import { i18n } from '@/i18n';
export default defineComponent({
props: {
channel: {
type: Object,
required: true
},
},
const props = defineProps<{
channel: Record<string, any>;
}>();
data() {
return {
};
},
computed: {
bannerStyle() {
if (this.channel.bannerUrl) {
return { backgroundImage: `url(${this.channel.bannerUrl})` };
} else {
return { backgroundColor: '#4c5e6d' };
}
}
},
const bannerStyle = computed(() => {
if (props.channel.bannerUrl) {
return { backgroundImage: `url(${props.channel.bannerUrl})` };
} else {
return { backgroundColor: '#4c5e6d' };
}
});
</script>

View File

@ -170,10 +170,10 @@ export default defineComponent({
aspectRatio: props.aspectRatio || 2.5,
layout: {
padding: {
left: 16,
right: 16,
top: 16,
bottom: 8,
left: 0,
right: 0,
top: 0,
bottom: 0,
},
},
scales: {

View File

@ -3,33 +3,17 @@
<pre v-else :class="`language-${prismLang}`"><code :class="`language-${prismLang}`" v-html="html"></code></pre>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { computed } from 'vue';
import 'prismjs';
import 'prismjs/themes/prism-okaidia.css';
export default defineComponent({
props: {
code: {
type: String,
required: true
},
lang: {
type: String,
required: false
},
inline: {
type: Boolean,
required: false
}
},
computed: {
prismLang() {
return Prism.languages[this.lang] ? this.lang : 'js';
},
html() {
return Prism.highlight(this.code, Prism.languages[this.prismLang], this.prismLang);
}
}
});
const props = defineProps<{
code: string;
lang?: string;
inline?: boolean;
}>();
const prismLang = computed(() => Prism.languages[props.lang] ? props.lang : 'js');
const html = computed(() => Prism.highlight(props.code, Prism.languages[prismLang.value], prismLang.value));
</script>

View File

@ -2,26 +2,14 @@
<XCode :code="code" :lang="lang" :inline="inline"/>
</template>
<script lang="ts">
import { defineComponent, defineAsyncComponent } from 'vue';
<script lang="ts" setup>
import { defineAsyncComponent } from 'vue';
export default defineComponent({
components: {
XCode: defineAsyncComponent(() => import('./code-core.vue'))
},
props: {
code: {
type: String,
required: true
},
lang: {
type: String,
required: false
},
inline: {
type: Boolean,
required: false
}
}
});
defineProps<{
code: string;
lang?: string;
inline?: boolean;
}>();
const XCode = defineAsyncComponent(() => import('./code-core.vue'));
</script>

View File

@ -1,45 +1,37 @@
<template>
<button class="nrvgflfu _button" @click="toggle">
<b>{{ modelValue ? $ts._cw.hide : $ts._cw.show }}</b>
<b>{{ modelValue ? i18n.locale._cw.hide : i18n.locale._cw.show }}</b>
<span v-if="!modelValue">{{ label }}</span>
</button>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { computed } from 'vue';
import { length } from 'stringz';
import * as misskey from 'misskey-js';
import { concat } from '@/scripts/array';
import { i18n } from '@/i18n';
export default defineComponent({
props: {
modelValue: {
type: Boolean,
required: true
},
note: {
type: Object,
required: true
}
},
const props = defineProps<{
modelValue: boolean;
note: misskey.entities.Note;
}>();
computed: {
label(): string {
return concat([
this.note.text ? [this.$t('_cw.chars', { count: length(this.note.text) })] : [],
this.note.files && this.note.files.length !== 0 ? [this.$t('_cw.files', { count: this.note.files.length }) ] : [],
this.note.poll != null ? [this.$ts.poll] : []
] as string[][]).join(' / ');
}
},
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void;
}>();
methods: {
length,
toggle() {
this.$emit('update:modelValue', !this.modelValue);
}
}
const label = computed(() => {
return concat([
props.note.text ? [i18n.t('_cw.chars', { count: length(props.note.text) })] : [],
props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length }) ] : [],
props.note.poll != null ? [i18n.locale.poll] : []
] as string[][]).join(' / ');
});
const toggle = () => {
emit('update:modelValue', !props.modelValue);
};
</script>
<style lang="scss" scoped>

View File

@ -1,6 +1,8 @@
<script lang="ts">
import { defineComponent, h, PropType, TransitionGroup } from 'vue';
import MkAd from '@/components/global/ad.vue';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
export default defineComponent({
props: {
@ -30,29 +32,29 @@ export default defineComponent({
},
},
methods: {
getDateText(time: string) {
setup(props, { slots, expose }) {
function getDateText(time: string) {
const date = new Date(time).getDate();
const month = new Date(time).getMonth() + 1;
return this.$t('monthAndDay', {
return i18n.t('monthAndDay', {
month: month.toString(),
day: date.toString()
});
}
},
render() {
if (this.items.length === 0) return;
if (props.items.length === 0) return;
const renderChildren = () => this.items.map((item, i) => {
const el = this.$slots.default({
const renderChildren = () => props.items.map((item, i) => {
if (!slots || !slots.default) return;
const el = slots.default({
item: item
})[0];
if (el.key == null && item.id) el.key = item.id;
if (
i != this.items.length - 1 &&
new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate()
i != props.items.length - 1 &&
new Date(item.createdAt).getDate() != new Date(props.items[i + 1].createdAt).getDate()
) {
const separator = h('div', {
class: 'separator',
@ -64,10 +66,10 @@ export default defineComponent({
h('i', {
class: 'fas fa-angle-up icon',
}),
this.getDateText(item.createdAt)
getDateText(item.createdAt)
]),
h('span', [
this.getDateText(this.items[i + 1].createdAt),
getDateText(props.items[i + 1].createdAt),
h('i', {
class: 'fas fa-angle-down icon',
})
@ -76,7 +78,7 @@ export default defineComponent({
return [el, separator];
} else {
if (this.ad && item._shouldInsertAd_) {
if (props.ad && item._shouldInsertAd_) {
return [h(MkAd, {
class: 'a', // advertiseの意(ブロッカー対策)
key: item.id + ':ad',
@ -88,18 +90,19 @@ export default defineComponent({
}
});
return h(this.$store.state.animation ? TransitionGroup : 'div', this.$store.state.animation ? {
class: 'sqadhkmv' + (this.noGap ? ' noGap' : ''),
name: 'list',
tag: 'div',
'data-direction': this.direction,
'data-reversed': this.reversed ? 'true' : 'false',
} : {
class: 'sqadhkmv' + (this.noGap ? ' noGap' : ''),
}, {
default: renderChildren
});
},
return () => h(
defaultStore.state.animation ? TransitionGroup : 'div',
defaultStore.state.animation ? {
class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
name: 'list',
tag: 'div',
'data-direction': props.direction,
'data-reversed': props.reversed ? 'true' : 'false',
} : {
class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
},
{ default: renderChildren });
}
});
</script>

View File

@ -1,65 +0,0 @@
<template>
<div v-size="{ max: [400] }" class="rbusrurv" :class="{ wide: forceWide }">
<slot></slot>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
props: {
forceWide: {
type: Boolean,
required: false,
default: false,
}
}
});
</script>
<style lang="scss" scoped>
.rbusrurv {
// 他のCSSからも参照されるので消さないように
--debobigegoXPadding: 32px;
--debobigegoYPadding: 32px;
--debobigegoContentHMargin: 16px;
font-size: 95%;
line-height: 1.3em;
background: var(--bg);
padding: var(--debobigegoYPadding) var(--debobigegoXPadding);
max-width: 750px;
margin: 0 auto;
&:not(.wide).max-width_400px {
--debobigegoXPadding: 0px;
> ::v-deep(*) {
._debobigegoPanel {
border: solid 0.5px var(--divider);
border-radius: 0;
border-left: none;
border-right: none;
}
._debobigego_group {
> *:not(._debobigegoNoConcat) {
&:not(:last-child):not(._debobigegoNoConcatPrev) {
&._debobigegoPanel, ._debobigegoPanel {
border-bottom: solid 0.5px var(--divider);
}
}
&:not(:first-child):not(._debobigegoNoConcatNext) {
&._debobigegoPanel, ._debobigegoPanel {
border-top: none;
}
}
}
}
}
}
}
</style>

View File

@ -1,81 +0,0 @@
<template>
<div class="yzpgjkxe _debobigegoItem">
<div class="_debobigegoLabel"><slot name="label"></slot></div>
<button class="main _button _debobigegoPanel _debobigegoClickable" :class="{ center, primary, danger }">
<slot></slot>
<div class="suffix">
<slot name="suffix"></slot>
<div class="icon">
<slot name="suffixIcon"></slot>
</div>
</div>
</button>
<div class="_debobigegoCaption"><slot name="desc"></slot></div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import './debobigego.scss';
export default defineComponent({
props: {
primary: {
type: Boolean,
required: false,
default: false,
},
danger: {
type: Boolean,
required: false,
default: false,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
center: {
type: Boolean,
required: false,
default: true,
}
},
});
</script>
<style lang="scss" scoped>
.yzpgjkxe {
> .main {
display: flex;
width: 100%;
box-sizing: border-box;
padding: 14px 16px;
text-align: left;
align-items: center;
&.center {
display: block;
text-align: center;
}
&.primary {
color: var(--accent);
}
&.danger {
color: #ff2a2a;
}
> .suffix {
display: inline-flex;
margin-left: auto;
opacity: 0.7;
> .icon {
margin-left: 1em;
}
}
}
}
</style>

View File

@ -1,52 +0,0 @@
._debobigegoPanel {
background: var(--panel);
border-radius: var(--radius);
transition: background 0.2s ease;
&._debobigegoClickable {
&:hover {
//background: var(--panelHighlight);
}
&:active {
background: var(--panelHighlight);
transition: background 0s;
}
}
}
._debobigegoLabel,
._debobigegoCaption {
font-size: 80%;
color: var(--fgTransparentWeak);
&:empty {
display: none;
}
}
._debobigegoLabel {
position: sticky;
top: var(--stickyTop, 0px);
z-index: 2;
margin: -8px calc(var(--debobigegoXPadding) * -1) 0 calc(var(--debobigegoXPadding) * -1);
padding: 8px calc(var(--debobigegoContentHMargin) + var(--debobigegoXPadding)) 8px calc(var(--debobigegoContentHMargin) + var(--debobigegoXPadding));
background: var(--X17);
-webkit-backdrop-filter: var(--blur, blur(10px));
backdrop-filter: var(--blur, blur(10px));
}
._themeChanging_ ._debobigegoLabel {
transition: none !important;
background: transparent;
}
._debobigegoCaption {
padding: 8px var(--debobigegoContentHMargin) 0 var(--debobigegoContentHMargin);
}
._debobigegoItem {
& + ._debobigegoItem {
margin-top: 24px;
}
}

View File

@ -1,78 +0,0 @@
<template>
<div v-size="{ max: [500] }" v-sticky-container class="vrtktovg _debobigegoItem _debobigegoNoConcat">
<div class="_debobigegoLabel"><slot name="label"></slot></div>
<div ref="child" class="main _debobigego_group">
<slot></slot>
</div>
<div class="_debobigegoCaption"><slot name="caption"></slot></div>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref } from 'vue';
export default defineComponent({
setup(props, context) {
const child = ref<HTMLElement | null>(null);
const scanChild = () => {
if (child.value == null) return;
const els = Array.from(child.value.children);
for (let i = 0; i < els.length; i++) {
const el = els[i];
if (el.classList.contains('_debobigegoNoConcat')) {
if (els[i - 1]) els[i - 1].classList.add('_debobigegoNoConcatPrev');
if (els[i + 1]) els[i + 1].classList.add('_debobigegoNoConcatNext');
}
}
};
onMounted(() => {
scanChild();
const observer = new MutationObserver(records => {
scanChild();
});
observer.observe(child.value, {
childList: true,
subtree: false,
attributes: false,
characterData: false,
});
});
return {
child
};
}
});
</script>
<style lang="scss" scoped>
.vrtktovg {
> .main {
> ::v-deep(*):not(._debobigegoNoConcat) {
&:not(._debobigegoNoConcatNext) {
margin: 0;
}
&:not(:last-child):not(._debobigegoNoConcatPrev) {
&._debobigegoPanel, ._debobigegoPanel {
border-bottom: solid 0.5px var(--divider);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
&:not(:first-child):not(._debobigegoNoConcatNext) {
&._debobigegoPanel, ._debobigegoPanel {
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
}
}
}
}
</style>

View File

@ -1,47 +0,0 @@
<template>
<div class="fzenkabp _debobigegoItem">
<div class="_debobigegoPanel" :class="{ warn }">
<i v-if="warn" class="fas fa-exclamation-triangle"></i>
<i v-else class="fas fa-info-circle"></i>
<slot></slot>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
props: {
warn: {
type: Boolean,
required: false,
default: false
},
},
data() {
return {
};
}
});
</script>
<style lang="scss" scoped>
.fzenkabp {
> div {
padding: 14px 16px;
font-size: 90%;
background: var(--infoBg);
color: var(--infoFg);
&.warn {
background: var(--infoWarnBg);
color: var(--infoWarnFg);
}
> i {
margin-right: 4px;
}
}
}
</style>

View File

@ -1,292 +0,0 @@
<template>
<FormGroup class="_debobigegoItem">
<template #label><slot></slot></template>
<div class="ztzhwixg _debobigegoItem" :class="{ inline, disabled }">
<div ref="icon" class="icon"><slot name="icon"></slot></div>
<div class="input _debobigegoPanel">
<div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div>
<input ref="inputEl"
v-model="v"
:type="type"
:disabled="disabled"
:required="required"
:readonly="readonly"
:placeholder="placeholder"
:pattern="pattern"
:autocomplete="autocomplete"
:spellcheck="spellcheck"
:step="step"
:list="id"
@focus="focused = true"
@blur="focused = false"
@keydown="onKeydown($event)"
@input="onInput"
>
<datalist v-if="datalist" :id="id">
<option v-for="data in datalist" :value="data"/>
</datalist>
<div ref="suffixEl" class="suffix"><slot name="suffix"></slot></div>
</div>
</div>
<template #caption><slot name="desc"></slot></template>
<FormButton v-if="manualSave && changed" primary @click="updated"><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
</FormGroup>
</template>
<script lang="ts">
import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
import './debobigego.scss';
import FormButton from './button.vue';
import FormGroup from './group.vue';
export default defineComponent({
components: {
FormGroup,
FormButton,
},
props: {
modelValue: {
required: false
},
type: {
type: String,
required: false
},
required: {
type: Boolean,
required: false
},
readonly: {
type: Boolean,
required: false
},
disabled: {
type: Boolean,
required: false
},
pattern: {
type: String,
required: false
},
placeholder: {
type: String,
required: false
},
autofocus: {
type: Boolean,
required: false,
default: false
},
autocomplete: {
required: false
},
spellcheck: {
required: false
},
step: {
required: false
},
datalist: {
type: Array,
required: false,
},
inline: {
type: Boolean,
required: false,
default: false
},
manualSave: {
type: Boolean,
required: false,
default: false
},
},
emits: ['change', 'keydown', 'enter', 'update:modelValue'],
setup(props, context) {
const { modelValue, type, autofocus } = toRefs(props);
const v = ref(modelValue.value);
const id = Math.random().toString(); // TODO: uuid?
const focused = ref(false);
const changed = ref(false);
const invalid = ref(false);
const filled = computed(() => v.value !== '' && v.value != null);
const inputEl = ref(null);
const prefixEl = ref(null);
const suffixEl = ref(null);
const focus = () => inputEl.value.focus();
const onInput = (ev) => {
changed.value = true;
context.emit('change', ev);
};
const onKeydown = (ev: KeyboardEvent) => {
context.emit('keydown', ev);
if (ev.code === 'Enter') {
context.emit('enter');
}
};
const updated = () => {
changed.value = false;
if (type?.value === 'number') {
context.emit('update:modelValue', parseFloat(v.value));
} else {
context.emit('update:modelValue', v.value);
}
};
watch(modelValue.value, newValue => {
v.value = newValue;
});
watch(v, newValue => {
if (!props.manualSave) {
updated();
}
invalid.value = inputEl.value.validity.badInput;
});
onMounted(() => {
nextTick(() => {
if (autofocus.value) {
focus();
}
// このコンポーネントが作成された時、非表示状態である場合がある
// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
const clock = setInterval(() => {
if (prefixEl.value) {
if (prefixEl.value.offsetWidth) {
inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
}
}
if (suffixEl.value) {
if (suffixEl.value.offsetWidth) {
inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px';
}
}
}, 100);
onUnmounted(() => {
clearInterval(clock);
});
});
});
return {
id,
v,
focused,
invalid,
changed,
filled,
inputEl,
prefixEl,
suffixEl,
focus,
onInput,
onKeydown,
updated,
};
},
});
</script>
<style lang="scss" scoped>
.ztzhwixg {
position: relative;
> .icon {
position: absolute;
top: 0;
left: 0;
width: 24px;
text-align: center;
line-height: 32px;
&:not(:empty) + .input {
margin-left: 28px;
}
}
> .input {
$height: 48px;
position: relative;
> input {
display: block;
height: $height;
width: 100%;
margin: 0;
padding: 0 16px;
font: inherit;
font-weight: normal;
font-size: 1em;
line-height: $height;
color: var(--inputText);
background: transparent;
border: none;
border-radius: 0;
outline: none;
box-shadow: none;
box-sizing: border-box;
&[type='file'] {
display: none;
}
}
> .prefix,
> .suffix {
display: block;
position: absolute;
z-index: 1;
top: 0;
padding: 0 16px;
font-size: 1em;
line-height: $height;
color: var(--inputLabel);
pointer-events: none;
&:empty {
display: none;
}
> * {
display: inline-block;
min-width: 16px;
max-width: 150px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
> .prefix {
left: 0;
padding-right: 8px;
}
> .suffix {
right: 0;
padding-left: 8px;
}
}
&.inline {
display: inline-block;
margin: 0;
}
&.disabled {
opacity: 0.7;
&, * {
cursor: not-allowed !important;
}
}
}
</style>

View File

@ -1,38 +0,0 @@
<template>
<div class="_debobigegoItem">
<div class="_debobigegoPanel anocepby">
<span class="key"><slot name="key"></slot></span>
<span class="value"><slot name="value"></slot></span>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import './debobigego.scss';
export default defineComponent({
});
</script>
<style lang="scss" scoped>
.anocepby {
display: flex;
align-items: center;
padding: 14px var(--debobigegoContentHMargin);
> .key {
margin-right: 12px;
white-space: nowrap;
}
> .value {
margin-left: auto;
opacity: 0.7;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
}
</style>

View File

@ -1,103 +0,0 @@
<template>
<div class="qmfkfnzi _debobigegoItem">
<a v-if="external" class="main _button _debobigegoPanel _debobigegoClickable" :href="to" target="_blank">
<span class="icon"><slot name="icon"></slot></span>
<span class="text"><slot></slot></span>
<span class="right">
<span class="text"><slot name="suffix"></slot></span>
<i class="fas fa-external-link-alt icon"></i>
</span>
</a>
<MkA v-else class="main _button _debobigegoPanel _debobigegoClickable" :class="{ active }" :to="to" :behavior="behavior">
<span class="icon"><slot name="icon"></slot></span>
<span class="text"><slot></slot></span>
<span class="right">
<span class="text"><slot name="suffix"></slot></span>
<i class="fas fa-chevron-right icon"></i>
</span>
</MkA>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import './debobigego.scss';
export default defineComponent({
props: {
to: {
type: String,
required: true
},
active: {
type: Boolean,
required: false
},
external: {
type: Boolean,
required: false
},
behavior: {
type: String,
required: false,
},
},
data() {
return {
};
}
});
</script>
<style lang="scss" scoped>
.qmfkfnzi {
> .main {
display: flex;
align-items: center;
width: 100%;
box-sizing: border-box;
padding: 14px 16px 14px 14px;
&:hover {
text-decoration: none;
}
&.active {
color: var(--accent);
background: var(--panelHighlight);
}
> .icon {
width: 32px;
margin-right: 2px;
flex-shrink: 0;
text-align: center;
opacity: 0.8;
&:empty {
display: none;
& + .text {
padding-left: 4px;
}
}
}
> .text {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
padding-right: 12px;
}
> .right {
margin-left: auto;
opacity: 0.7;
> .text:not(:empty) {
margin-right: 0.75em;
}
}
}
}
</style>

View File

@ -1,102 +0,0 @@
<template>
<FormGroup class="_debobigegoItem">
<template #label><slot></slot></template>
<div class="drooglns _debobigegoItem" :class="{ tall }">
<div class="input _debobigegoPanel">
<textarea v-model="v"
class="_monospace"
readonly
:spellcheck="false"
></textarea>
</div>
</div>
<template #caption><slot name="desc"></slot></template>
</FormGroup>
</template>
<script lang="ts">
import { defineComponent, ref, toRefs, watch } from 'vue';
import * as JSON5 from 'json5';
import './debobigego.scss';
import FormGroup from './group.vue';
export default defineComponent({
components: {
FormGroup,
},
props: {
value: {
required: false
},
tall: {
type: Boolean,
required: false,
default: false
},
pre: {
type: Boolean,
required: false,
default: false
},
manualSave: {
type: Boolean,
required: false,
default: false
},
},
setup(props, context) {
const { value } = toRefs(props);
const v = ref('');
watch(() => value, newValue => {
v.value = JSON5.stringify(newValue.value, null, '\t');
}, {
immediate: true
});
return {
v,
};
}
});
</script>
<style lang="scss" scoped>
.drooglns {
position: relative;
> .input {
position: relative;
> textarea {
display: block;
width: 100%;
min-width: 100%;
max-width: 100%;
min-height: 130px;
margin: 0;
padding: 16px var(--debobigegoContentHMargin);
box-sizing: border-box;
font: inherit;
font-weight: normal;
font-size: 1em;
background: transparent;
border: none;
border-radius: 0;
outline: none;
box-shadow: none;
color: var(--fg);
tab-size: 2;
white-space: pre;
}
}
&.tall {
> .input {
> textarea {
min-height: 200px;
}
}
}
}
</style>

View File

@ -1,42 +0,0 @@
<template>
<FormGroup class="uljviswt _debobigegoItem">
<template #label><slot name="label"></slot></template>
<slot :items="items"></slot>
<div v-if="empty" key="_empty_" class="empty">
<slot name="empty"></slot>
</div>
<FormButton v-show="more" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary @click="fetchMore">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template>
</FormButton>
</FormGroup>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import FormButton from './button.vue';
import FormGroup from './group.vue';
import paging from '@/scripts/paging';
export default defineComponent({
components: {
FormButton,
FormGroup,
},
mixins: [
paging({}),
],
props: {
pagination: {
required: true
},
},
});
</script>
<style lang="scss" scoped>
.uljviswt {
}
</style>

View File

@ -1,112 +0,0 @@
<script lang="ts">
import { defineComponent, h } from 'vue';
import MkRadio from '@/components/form/radio.vue';
import './debobigego.scss';
export default defineComponent({
components: {
MkRadio
},
props: {
modelValue: {
required: false
},
},
data() {
return {
value: this.modelValue,
}
},
watch: {
modelValue() {
this.value = this.modelValue;
},
value() {
this.$emit('update:modelValue', this.value);
}
},
render() {
const label = this.$slots.desc();
let options = this.$slots.default();
// なぜかFragmentになることがあるため
if (options.length === 1 && options[0].props == null) options = options[0].children;
return h('div', {
class: 'cnklmpwm _debobigegoItem'
}, [
h('div', {
class: '_debobigegoLabel',
}, label),
...options.map(option => h('button', {
class: '_button _debobigegoPanel _debobigegoClickable',
key: option.key,
onClick: () => this.value = option.props.value,
}, [h('span', {
class: ['check', { checked: this.value === option.props.value }],
}), option.children]))
]);
}
});
</script>
<style lang="scss">
.cnklmpwm {
> button {
display: block;
width: 100%;
box-sizing: border-box;
padding: 14px 18px;
text-align: left;
&:not(:first-of-type) {
border-top: none !important;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
&:not(:last-of-type) {
border-bottom: solid 0.5px var(--divider);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
> .check {
display: inline-block;
vertical-align: bottom;
position: relative;
width: 16px;
height: 16px;
margin-right: 8px;
background: none;
border: 2px solid var(--inputBorder);
border-radius: 100%;
transition: inherit;
&:after {
content: "";
display: block;
position: absolute;
top: 3px;
right: 3px;
bottom: 3px;
left: 3px;
border-radius: 100%;
opacity: 0;
transform: scale(0);
transition: .4s cubic-bezier(.25,.8,.25,1);
}
&.checked {
border-color: var(--accent);
&:after {
background-color: var(--accent);
transform: scale(1);
opacity: 1;
}
}
}
}
}
</style>

View File

@ -1,122 +0,0 @@
<template>
<div class="ifitouly _debobigegoItem" :class="{ focused, disabled }">
<div class="_debobigegoLabel"><slot name="label"></slot></div>
<div class="_debobigegoPanel main">
<input
ref="input"
v-model="v"
type="range"
:disabled="disabled"
:min="min"
:max="max"
:step="step"
@focus="focused = true"
@blur="focused = false"
@input="$emit('update:value', $event.target.value)"
/>
</div>
<div class="_debobigegoCaption"><slot name="caption"></slot></div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
props: {
value: {
type: Number,
required: false,
default: 0
},
disabled: {
type: Boolean,
required: false,
default: false
},
min: {
type: Number,
required: false,
default: 0
},
max: {
type: Number,
required: false,
default: 100
},
step: {
type: Number,
required: false,
default: 1
},
},
data() {
return {
v: this.value,
focused: false
};
},
watch: {
value(v) {
this.v = parseFloat(v);
}
},
});
</script>
<style lang="scss" scoped>
.ifitouly {
position: relative;
> .main {
padding: 22px 16px;
> input {
display: block;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background: var(--X10);
height: 4px;
width: 100%;
box-sizing: border-box;
margin: 0;
outline: 0;
border: 0;
border-radius: 7px;
&.disabled {
opacity: 0.6;
cursor: not-allowed;
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
cursor: pointer;
width: 20px;
height: 20px;
display: block;
border-radius: 50%;
border: none;
background: var(--accent);
box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
box-sizing: content-box;
}
&::-moz-range-thumb {
-moz-appearance: none;
appearance: none;
cursor: pointer;
width: 20px;
height: 20px;
display: block;
border-radius: 50%;
border: none;
background: var(--accent);
box-shadow: 0 0 6px rgba(0, 0, 0, 0.3);
}
}
}
}
</style>

View File

@ -1,145 +0,0 @@
<template>
<div class="yrtfrpux _debobigegoItem" :class="{ disabled, inline }">
<div class="_debobigegoLabel"><slot name="label"></slot></div>
<div ref="icon" class="icon"><slot name="icon"></slot></div>
<div class="input _debobigegoPanel _debobigegoClickable" @click="focus">
<div ref="prefix" class="prefix"><slot name="prefix"></slot></div>
<select ref="input"
v-model="v"
:required="required"
:disabled="disabled"
@focus="focused = true"
@blur="focused = false"
>
<slot></slot>
</select>
<div class="suffix">
<i class="fas fa-chevron-down"></i>
</div>
</div>
<div class="_debobigegoCaption"><slot name="caption"></slot></div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import './debobigego.scss';
export default defineComponent({
props: {
modelValue: {
required: false
},
required: {
type: Boolean,
required: false
},
disabled: {
type: Boolean,
required: false
},
inline: {
type: Boolean,
required: false,
default: false
},
},
data() {
return {
};
},
computed: {
v: {
get() {
return this.modelValue;
},
set(v) {
this.$emit('update:modelValue', v);
}
},
},
methods: {
focus() {
this.$refs.input.focus();
}
}
});
</script>
<style lang="scss" scoped>
.yrtfrpux {
position: relative;
> .icon {
position: absolute;
top: 0;
left: 0;
width: 24px;
text-align: center;
line-height: 32px;
&:not(:empty) + .input {
margin-left: 28px;
}
}
> .input {
display: flex;
position: relative;
> select {
display: block;
flex: 1;
width: 100%;
padding: 0 16px;
font: inherit;
font-weight: normal;
font-size: 1em;
height: 48px;
background: none;
border: none;
border-radius: 0;
outline: none;
box-shadow: none;
appearance: none;
-webkit-appearance: none;
color: var(--fg);
option,
optgroup {
color: var(--fg);
background: var(--bg);
}
}
> .prefix,
> .suffix {
display: block;
align-self: center;
justify-self: center;
font-size: 1em;
line-height: 32px;
color: var(--inputLabel);
pointer-events: none;
&:empty {
display: none;
}
> * {
display: block;
min-width: 16px;
}
}
> .prefix {
padding-right: 4px;
}
> .suffix {
padding: 0 16px 0 0;
opacity: 0.7;
}
}
}
</style>

View File

@ -1,101 +0,0 @@
<template>
<transition name="fade" mode="out-in">
<div v-if="pending" class="_debobigegoItem">
<div class="_debobigegoPanel">
<MkLoading/>
</div>
</div>
<div v-else-if="resolved" class="_debobigegoItem">
<slot :result="result"></slot>
</div>
<div v-else class="_debobigegoItem">
<div class="_debobigegoPanel eiurkvay">
<div><i class="fas fa-exclamation-triangle"></i> {{ $ts.somethingHappened }}</div>
<MkButton inline class="retry" @click="retry"><i class="fas fa-redo-alt"></i> {{ $ts.retry }}</MkButton>
</div>
</div>
</transition>
</template>
<script lang="ts">
import { defineComponent, PropType, ref, watch } from 'vue';
import './debobigego.scss';
import MkButton from '@/components/ui/button.vue';
export default defineComponent({
components: {
MkButton
},
props: {
p: {
type: Function as PropType<() => Promise<any>>,
required: true,
}
},
setup(props, context) {
const pending = ref(true);
const resolved = ref(false);
const rejected = ref(false);
const result = ref(null);
const process = () => {
if (props.p == null) {
return;
}
const promise = props.p();
pending.value = true;
resolved.value = false;
rejected.value = false;
promise.then((_result) => {
pending.value = false;
resolved.value = true;
result.value = _result;
});
promise.catch(() => {
pending.value = false;
rejected.value = true;
});
};
watch(() => props.p, () => {
process();
}, {
immediate: true
});
const retry = () => {
process();
};
return {
pending,
resolved,
rejected,
result,
retry,
};
}
});
</script>
<style lang="scss" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.125s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.eiurkvay {
padding: 16px;
text-align: center;
> .retry {
margin-top: 16px;
}
}
</style>

View File

@ -1,132 +0,0 @@
<template>
<div class="ijnpvmgr _debobigegoItem">
<div class="main _debobigegoPanel _debobigegoClickable"
:class="{ disabled, checked }"
:aria-checked="checked"
:aria-disabled="disabled"
@click.prevent="toggle"
>
<input
ref="input"
type="checkbox"
:disabled="disabled"
@keydown.enter="toggle"
>
<span v-tooltip="checked ? $ts.itsOn : $ts.itsOff" class="button">
<span class="handle"></span>
</span>
<span class="label">
<span><slot></slot></span>
</span>
</div>
<div class="_debobigegoCaption"><slot name="desc"></slot></div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import './debobigego.scss';
export default defineComponent({
props: {
modelValue: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
},
computed: {
checked(): boolean {
return this.modelValue;
}
},
methods: {
toggle() {
if (this.disabled) return;
this.$emit('update:modelValue', !this.checked);
}
}
});
</script>
<style lang="scss" scoped>
.ijnpvmgr {
> .main {
position: relative;
display: flex;
padding: 14px 16px;
cursor: pointer;
> * {
user-select: none;
}
> input {
position: absolute;
width: 0;
height: 0;
opacity: 0;
margin: 0;
}
> .button {
position: relative;
display: inline-block;
flex-shrink: 0;
margin: 0;
width: 34px;
height: 22px;
background: var(--switchBg);
outline: none;
border-radius: 999px;
transition: all 0.3s;
cursor: pointer;
> .handle {
position: absolute;
top: 0;
left: 3px;
bottom: 0;
margin: auto 0;
border-radius: 100%;
transition: background-color 0.3s, transform 0.3s;
width: 16px;
height: 16px;
background-color: #fff;
pointer-events: none;
}
}
> .label {
margin-left: 12px;
display: block;
transition: inherit;
color: var(--fg);
> span {
display: block;
line-height: 20px;
transition: inherit;
}
}
&.disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.checked {
> .button {
background-color: var(--accent);
> .handle {
transform: translateX(12px);
}
}
}
}
}
</style>

View File

@ -1,161 +0,0 @@
<template>
<FormGroup class="_debobigegoItem">
<template #label><slot></slot></template>
<div class="rivhosbp _debobigegoItem" :class="{ tall, pre }">
<div class="input _debobigegoPanel">
<textarea ref="input" v-model="v"
:class="{ code, _monospace: code }"
:required="required"
:readonly="readonly"
:pattern="pattern"
:autocomplete="autocomplete"
:spellcheck="!code"
@input="onInput"
@focus="focused = true"
@blur="focused = false"
></textarea>
</div>
</div>
<template #caption><slot name="desc"></slot></template>
<FormButton v-if="manualSave && changed" primary @click="updated"><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
</FormGroup>
</template>
<script lang="ts">
import { defineComponent, ref, toRefs, watch } from 'vue';
import './debobigego.scss';
import FormButton from './button.vue';
import FormGroup from './group.vue';
export default defineComponent({
components: {
FormGroup,
FormButton,
},
props: {
modelValue: {
required: false
},
required: {
type: Boolean,
required: false
},
readonly: {
type: Boolean,
required: false
},
pattern: {
type: String,
required: false
},
autocomplete: {
type: String,
required: false
},
code: {
type: Boolean,
required: false
},
tall: {
type: Boolean,
required: false,
default: false
},
pre: {
type: Boolean,
required: false,
default: false
},
manualSave: {
type: Boolean,
required: false,
default: false
},
},
setup(props, context) {
const { modelValue } = toRefs(props);
const v = ref(modelValue.value);
const changed = ref(false);
const inputEl = ref(null);
const focus = () => inputEl.value.focus();
const onInput = (ev) => {
changed.value = true;
context.emit('change', ev);
};
const updated = () => {
changed.value = false;
context.emit('update:modelValue', v.value);
};
watch(modelValue.value, newValue => {
v.value = newValue;
});
watch(v, newValue => {
if (!props.manualSave) {
updated();
}
});
return {
v,
updated,
changed,
focus,
onInput,
};
}
});
</script>
<style lang="scss" scoped>
.rivhosbp {
position: relative;
> .input {
position: relative;
> textarea {
display: block;
width: 100%;
min-width: 100%;
max-width: 100%;
min-height: 130px;
margin: 0;
padding: 16px;
box-sizing: border-box;
font: inherit;
font-weight: normal;
font-size: 1em;
background: transparent;
border: none;
border-radius: 0;
outline: none;
box-shadow: none;
color: var(--fg);
&.code {
tab-size: 2;
}
}
}
&.tall {
> .input {
> textarea {
min-height: 200px;
}
}
}
&.pre {
> .input {
> textarea {
white-space: pre;
}
}
}
}
</style>

View File

@ -1,36 +0,0 @@
<template>
<div v-size="{ max: [500] }" class="wthhikgt _debobigegoItem">
<slot></slot>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
});
</script>
<style lang="scss" scoped>
.wthhikgt {
position: relative;
display: flex;
> ::v-deep(*) {
flex: 1;
margin: 0;
&:not(:last-child) {
margin-right: 16px;
}
}
&.max-width_500px {
display: block;
> ::v-deep(*) {
margin: inherit;
}
}
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="done(true)" @closed="$emit('closed')">
<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="done(true)" @closed="emit('closed')">
<div class="mk-dialog">
<div v-if="icon" class="icon">
<i :class="icon"></i>
@ -14,7 +14,7 @@
</div>
<header v-if="title"><Mfm :text="title"/></header>
<div v-if="text" class="body"><Mfm :text="text"/></div>
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder" @keydown="onInputKeydown">
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" @keydown="onInputKeydown">
<template v-if="input.type === 'password'" #prefix><i class="fas fa-lock"></i></template>
</MkInput>
<MkSelect v-if="select" v-model="selectedValue" autofocus>
@ -28,8 +28,8 @@
</template>
</MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" class="buttons">
<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" @click="ok">{{ (showCancelButton || input || select) ? $ts.ok : $ts.gotIt }}</MkButton>
<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ $ts.cancel }}</MkButton>
<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" @click="ok">{{ (showCancelButton || input || select) ? i18n.locale.ok : i18n.locale.gotIt }}</MkButton>
<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ i18n.locale.cancel }}</MkButton>
</div>
<div v-if="actions" class="buttons">
<MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" @click="() => { action.callback(); close(); }">{{ action.text }}</MkButton>
@ -38,118 +38,108 @@
</MkModal>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref } from 'vue';
import MkModal from '@/components/ui/modal.vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkSelect from '@/components/form/select.vue';
import { i18n } from '@/i18n';
export default defineComponent({
components: {
MkModal,
MkButton,
MkInput,
MkSelect,
},
type Input = {
type: HTMLInputElement['type'];
placeholder?: string | null;
default: any | null;
};
props: {
type: {
type: String,
required: false,
default: 'info'
},
title: {
type: String,
required: false
},
text: {
type: String,
required: false
},
input: {
required: false
},
select: {
required: false
},
icon: {
required: false
},
actions: {
required: false
},
showOkButton: {
type: Boolean,
default: true
},
showCancelButton: {
type: Boolean,
default: false
},
cancelableByBgClick: {
type: Boolean,
default: true
},
},
type Select = {
items: {
value: string;
text: string;
}[];
groupedItems: {
label: string;
items: {
value: string;
text: string;
}[];
}[];
default: string | null;
};
emits: ['done', 'closed'],
const props = withDefaults(defineProps<{
type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting';
title: string;
text?: string;
input?: Input;
select?: Select;
icon?: string;
actions?: {
text: string;
primary?: boolean,
callback: (...args: any[]) => void;
}[];
showOkButton?: boolean;
showCancelButton?: boolean;
cancelableByBgClick?: boolean;
}>(), {
type: 'info',
showOkButton: true,
showCancelButton: false,
cancelableByBgClick: true,
});
data() {
return {
inputValue: this.input && this.input.default ? this.input.default : null,
selectedValue: this.select ? this.select.default ? this.select.default : this.select.items ? this.select.items[0].value : this.select.groupedItems[0].items[0].value : null,
};
},
const emit = defineEmits<{
(e: 'done', v: { canceled: boolean; result: any }): void;
(e: 'closed'): void;
}>();
mounted() {
document.addEventListener('keydown', this.onKeydown);
},
const modal = ref<InstanceType<typeof MkModal>>();
beforeUnmount() {
document.removeEventListener('keydown', this.onKeydown);
},
const inputValue = ref(props.input?.default || null);
const selectedValue = ref(props.select?.default || null);
methods: {
done(canceled, result?) {
this.$emit('done', { canceled, result });
this.$refs.modal.close();
},
function done(canceled: boolean, result?) {
emit('done', { canceled, result });
modal.value?.close();
}
async ok() {
if (!this.showOkButton) return;
async function ok() {
if (!props.showOkButton) return;
const result =
this.input ? this.inputValue :
this.select ? this.selectedValue :
true;
this.done(false, result);
},
const result =
props.input ? inputValue.value :
props.select ? selectedValue.value :
true;
done(false, result);
}
cancel() {
this.done(true);
},
function cancel() {
done(true);
}
/*
function onBgClick() {
if (props.cancelableByBgClick) cancel();
}
*/
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') cancel();
}
onBgClick() {
if (this.cancelableByBgClick) {
this.cancel();
}
},
onKeydown(e) {
if (e.which === 27) { // ESC
this.cancel();
}
},
onInputKeydown(e) {
if (e.which === 13) { // Enter
e.preventDefault();
e.stopPropagation();
this.ok();
}
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
ok();
}
}
onMounted(() => {
document.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeydown);
});
</script>

View File

@ -14,71 +14,42 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { computed } from 'vue';
import * as Misskey from 'misskey-js';
import ImgWithBlurhash from '@/components/img-with-blurhash.vue';
import { ColdDeviceStorage } from '@/store';
export default defineComponent({
components: {
ImgWithBlurhash
},
props: {
file: {
type: Object,
required: true
},
fit: {
type: String,
required: false,
default: 'cover'
},
},
data() {
return {
isContextmenuShowing: false,
isDragging: false,
const props = defineProps<{
file: Misskey.entities.DriveFile;
fit: string;
}>();
};
},
computed: {
is(): 'image' | 'video' | 'midi' | 'audio' | 'csv' | 'pdf' | 'textfile' | 'archive' | 'unknown' {
if (this.file.type.startsWith('image/')) return 'image';
if (this.file.type.startsWith('video/')) return 'video';
if (this.file.type === 'audio/midi') return 'midi';
if (this.file.type.startsWith('audio/')) return 'audio';
if (this.file.type.endsWith('/csv')) return 'csv';
if (this.file.type.endsWith('/pdf')) return 'pdf';
if (this.file.type.startsWith('text/')) return 'textfile';
if ([
"application/zip",
"application/x-cpio",
"application/x-bzip",
"application/x-bzip2",
"application/java-archive",
"application/x-rar-compressed",
"application/x-tar",
"application/gzip",
"application/x-7z-compressed"
].some(e => e === this.file.type)) return 'archive';
return 'unknown';
},
isThumbnailAvailable(): boolean {
return this.file.thumbnailUrl
? (this.is === 'image' || this.is === 'video')
: false;
},
},
mounted() {
const audioTag = this.$refs.volumectrl as HTMLAudioElement;
if (audioTag) audioTag.volume = ColdDeviceStorage.get('mediaVolume');
},
methods: {
volumechange() {
const audioTag = this.$refs.volumectrl as HTMLAudioElement;
ColdDeviceStorage.set('mediaVolume', audioTag.volume);
}
}
const is = computed(() => {
if (props.file.type.startsWith('image/')) return 'image';
if (props.file.type.startsWith('video/')) return 'video';
if (props.file.type === 'audio/midi') return 'midi';
if (props.file.type.startsWith('audio/')) return 'audio';
if (props.file.type.endsWith('/csv')) return 'csv';
if (props.file.type.endsWith('/pdf')) return 'pdf';
if (props.file.type.startsWith('text/')) return 'textfile';
if ([
"application/zip",
"application/x-cpio",
"application/x-bzip",
"application/x-bzip2",
"application/java-archive",
"application/x-rar-compressed",
"application/x-tar",
"application/gzip",
"application/x-7z-compressed"
].some(e => e === props.file.type)) return 'archive';
return 'unknown';
});
const isThumbnailAvailable = computed(() => {
return props.file.thumbnailUrl
? (is.value === 'image' as const || is.value === 'video')
: false;
});
</script>

View File

@ -7,64 +7,51 @@
@click="cancel()"
@close="cancel()"
@ok="ok()"
@closed="$emit('closed')"
@closed="emit('closed')"
>
<template #header>
{{ multiple ? ((type === 'file') ? $ts.selectFiles : $ts.selectFolders) : ((type === 'file') ? $ts.selectFile : $ts.selectFolder) }}
{{ multiple ? ((type === 'file') ? i18n.locale.selectFiles : i18n.locale.selectFolders) : ((type === 'file') ? i18n.locale.selectFile : i18n.locale.selectFolder) }}
<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span>
</template>
<XDrive :multiple="multiple" :select="type" @changeSelection="onChangeSelection" @selected="ok()"/>
</XModalWindow>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import XDrive from './drive.vue';
import XModalWindow from '@/components/ui/modal-window.vue';
import number from '@/filters/number';
import { i18n } from '@/i18n';
export default defineComponent({
components: {
XDrive,
XModalWindow,
},
props: {
type: {
type: String,
required: false,
default: 'file'
},
multiple: {
type: Boolean,
default: false
}
},
emits: ['done', 'closed'],
data() {
return {
selected: []
};
},
methods: {
ok() {
this.$emit('done', this.selected);
this.$refs.dialog.close();
},
cancel() {
this.$emit('done');
this.$refs.dialog.close();
},
onChangeSelection(xs) {
this.selected = xs;
},
number
}
withDefaults(defineProps<{
type?: 'file' | 'folder';
multiple: boolean;
}>(), {
type: 'file',
});
const emit = defineEmits<{
(e: 'done', r?: Misskey.entities.DriveFile[]): void;
(e: 'closed'): void;
}>();
const dialog = ref<InstanceType<typeof XModalWindow>>();
const selected = ref<Misskey.entities.DriveFile[]>([]);
function ok() {
emit('done', selected.value);
dialog.value?.close();
}
function cancel() {
emit('done');
dialog.value?.close();
}
function onChangeSelection(files: Misskey.entities.DriveFile[]) {
selected.value = files;
}
</script>

View File

@ -3,42 +3,27 @@
:initial-width="800"
:initial-height="500"
:can-resize="true"
@closed="$emit('closed')"
@closed="emit('closed')"
>
<template #header>
{{ $ts.drive }}
{{ i18n.locale.drive }}
</template>
<XDrive :initial-folder="initialFolder"/>
</XWindow>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
import * as Misskey from 'misskey-js';
import XDrive from './drive.vue';
import XWindow from '@/components/ui/window.vue';
import { i18n } from '@/i18n';
export default defineComponent({
components: {
XDrive,
XWindow,
},
defineProps<{
initialFolder?: Misskey.entities.DriveFolder;
}>();
props: {
initialFolder: {
type: Object,
required: false
},
},
emits: ['closed'],
data() {
return {
};
},
methods: {
}
});
const emit = defineEmits<{
(e: 'closed'): void;
}>();
</script>

View File

@ -8,17 +8,17 @@
@dragstart="onDragstart"
@dragend="onDragend"
>
<div v-if="$i.avatarId == file.id" class="label">
<div v-if="$i?.avatarId == file.id" class="label">
<img src="/client-assets/label.svg"/>
<p>{{ $ts.avatar }}</p>
<p>{{ i18n.locale.avatar }}</p>
</div>
<div v-if="$i.bannerId == file.id" class="label">
<div v-if="$i?.bannerId == file.id" class="label">
<img src="/client-assets/label.svg"/>
<p>{{ $ts.banner }}</p>
<p>{{ i18n.locale.banner }}</p>
</div>
<div v-if="file.isSensitive" class="label red">
<img src="/client-assets/label-red.svg"/>
<p>{{ $ts.nsfw }}</p>
<p>{{ i18n.locale.nsfw }}</p>
</div>
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
@ -30,179 +30,155 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import MkDriveFileThumbnail from './drive-file-thumbnail.vue';
import bytes from '@/filters/bytes';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { $i } from '@/account';
export default defineComponent({
components: {
MkDriveFileThumbnail
},
props: {
file: {
type: Object,
required: true,
},
isSelected: {
type: Boolean,
required: false,
default: false,
},
selectMode: {
type: Boolean,
required: false,
default: false,
}
},
emits: ['chosen'],
data() {
return {
isDragging: false
};
},
computed: {
// TODO: parentへの参照を無くす
browser(): any {
return this.$parent;
},
title(): string {
return `${this.file.name}\n${this.file.type} ${bytes(this.file.size)}`;
}
},
methods: {
getMenu() {
return [{
text: this.$ts.rename,
icon: 'fas fa-i-cursor',
action: this.rename
}, {
text: this.file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive,
icon: this.file.isSensitive ? 'fas fa-eye' : 'fas fa-eye-slash',
action: this.toggleSensitive
}, {
text: this.$ts.describeFile,
icon: 'fas fa-i-cursor',
action: this.describe
}, null, {
text: this.$ts.copyUrl,
icon: 'fas fa-link',
action: this.copyUrl
}, {
type: 'a',
href: this.file.url,
target: '_blank',
text: this.$ts.download,
icon: 'fas fa-download',
download: this.file.name
}, null, {
text: this.$ts.delete,
icon: 'fas fa-trash-alt',
danger: true,
action: this.deleteFile
}];
},
onClick(ev) {
if (this.selectMode) {
this.$emit('chosen', this.file);
} else {
os.popupMenu(this.getMenu(), ev.currentTarget || ev.target);
}
},
onContextmenu(e) {
os.contextMenu(this.getMenu(), e);
},
onDragstart(e) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(this.file));
this.isDragging = true;
// 親ブラウザに対して、ドラッグが開始されたフラグを立てる
// (=あなたの子供が、ドラッグを開始しましたよ)
this.browser.isDragSource = true;
},
onDragend(e) {
this.isDragging = false;
this.browser.isDragSource = false;
},
rename() {
os.inputText({
title: this.$ts.renameFile,
placeholder: this.$ts.inputNewFileName,
default: this.file.name,
allowEmpty: false
}).then(({ canceled, result: name }) => {
if (canceled) return;
os.api('drive/files/update', {
fileId: this.file.id,
name: name
});
});
},
describe() {
os.popup(import('@/components/media-caption.vue'), {
title: this.$ts.describeFile,
input: {
placeholder: this.$ts.inputNewDescription,
default: this.file.comment !== null ? this.file.comment : '',
},
image: this.file
}, {
done: result => {
if (!result || result.canceled) return;
let comment = result.result;
os.api('drive/files/update', {
fileId: this.file.id,
comment: comment.length == 0 ? null : comment
});
}
}, 'closed');
},
toggleSensitive() {
os.api('drive/files/update', {
fileId: this.file.id,
isSensitive: !this.file.isSensitive
});
},
copyUrl() {
copyToClipboard(this.file.url);
os.success();
},
addApp() {
alert('not implemented yet');
},
async deleteFile() {
const { canceled } = await os.confirm({
type: 'warning',
text: this.$t('driveFileDeleteConfirm', { name: this.file.name }),
});
if (canceled) return;
os.api('drive/files/delete', {
fileId: this.file.id
});
},
bytes
}
const props = withDefaults(defineProps<{
file: Misskey.entities.DriveFile;
isSelected?: boolean;
selectMode?: boolean;
}>(), {
isSelected: false,
selectMode: false,
});
const emit = defineEmits<{
(e: 'chosen', r: Misskey.entities.DriveFile): void;
(e: 'dragstart'): void;
(e: 'dragend'): void;
}>();
const isDragging = ref(false);
const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`);
function getMenu() {
return [{
text: i18n.locale.rename,
icon: 'fas fa-i-cursor',
action: rename
}, {
text: props.file.isSensitive ? i18n.locale.unmarkAsSensitive : i18n.locale.markAsSensitive,
icon: props.file.isSensitive ? 'fas fa-eye' : 'fas fa-eye-slash',
action: toggleSensitive
}, {
text: i18n.locale.describeFile,
icon: 'fas fa-i-cursor',
action: describe
}, null, {
text: i18n.locale.copyUrl,
icon: 'fas fa-link',
action: copyUrl
}, {
type: 'a',
href: props.file.url,
target: '_blank',
text: i18n.locale.download,
icon: 'fas fa-download',
download: props.file.name
}, null, {
text: i18n.locale.delete,
icon: 'fas fa-trash-alt',
danger: true,
action: deleteFile
}];
}
function onClick(ev: MouseEvent) {
if (props.selectMode) {
emit('chosen', props.file);
} else {
os.popupMenu(getMenu(), (ev.currentTarget || ev.target || undefined) as HTMLElement | undefined);
}
}
function onContextmenu(e: MouseEvent) {
os.contextMenu(getMenu(), e);
}
function onDragstart(e: DragEvent) {
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(props.file));
}
isDragging.value = true;
emit('dragstart');
}
function onDragend() {
isDragging.value = false;
emit('dragend');
}
function rename() {
os.inputText({
title: i18n.locale.renameFile,
placeholder: i18n.locale.inputNewFileName,
default: props.file.name,
}).then(({ canceled, result: name }) => {
if (canceled) return;
os.api('drive/files/update', {
fileId: props.file.id,
name: name
});
});
}
function describe() {
os.popup(import('@/components/media-caption.vue'), {
title: i18n.locale.describeFile,
input: {
placeholder: i18n.locale.inputNewDescription,
default: props.file.comment !== null ? props.file.comment : '',
},
image: props.file
}, {
done: result => {
if (!result || result.canceled) return;
let comment = result.result;
os.api('drive/files/update', {
fileId: props.file.id,
comment: comment.length == 0 ? null : comment
});
}
}, 'closed');
}
function toggleSensitive() {
os.api('drive/files/update', {
fileId: props.file.id,
isSensitive: !props.file.isSensitive
});
}
function copyUrl() {
copyToClipboard(props.file.url);
os.success();
}
/*
function addApp() {
alert('not implemented yet');
}
*/
async function deleteFile() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('driveFileDeleteConfirm', { name: props.file.name }),
});
if (canceled) return;
os.api('drive/files/delete', {
fileId: props.file.id
});
}
</script>
<style lang="scss" scoped>

View File

@ -19,243 +19,233 @@
<template v-if="!hover"><i class="fas fa-folder fa-fw"></i></template>
{{ folder.name }}
</p>
<p v-if="$store.state.uploadFolder == folder.id" class="upload">
{{ $ts.uploadFolder }}
<p v-if="defaultStore.state.uploadFolder == folder.id" class="upload">
{{ i18n.locale.uploadFolder }}
</p>
<button v-if="selectMode" class="checkbox _button" :class="{ checked: isSelected }" @click.prevent.stop="checkboxClicked"></button>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
export default defineComponent({
props: {
folder: {
type: Object,
required: true,
},
isSelected: {
type: Boolean,
required: false,
default: false,
},
selectMode: {
type: Boolean,
required: false,
default: false,
}
},
emits: ['chosen'],
data() {
return {
hover: false,
draghover: false,
isDragging: false,
};
},
computed: {
browser(): any {
return this.$parent;
},
title(): string {
return this.folder.name;
}
},
methods: {
checkboxClicked(e) {
this.$emit('chosen', this.folder);
},
onClick() {
this.browser.move(this.folder);
},
onMouseover() {
this.hover = true;
},
onMouseout() {
this.hover = false
},
onDragover(e) {
// 自分自身がドラッグされている場合
if (this.isDragging) {
// 自分自身にはドロップさせない
e.dataTransfer.dropEffect = 'none';
return;
}
const isFile = e.dataTransfer.items[0].kind == 'file';
const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_;
if (isFile || isDriveFile || isDriveFolder) {
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
} else {
e.dataTransfer.dropEffect = 'none';
}
},
onDragenter() {
if (!this.isDragging) this.draghover = true;
},
onDragleave() {
this.draghover = false;
},
onDrop(e) {
this.draghover = false;
// ファイルだったら
if (e.dataTransfer.files.length > 0) {
for (const file of Array.from(e.dataTransfer.files)) {
this.browser.upload(file, this.folder);
}
return;
}
//#region ドライブのファイル
const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile != '') {
const file = JSON.parse(driveFile);
this.browser.removeFile(file.id);
os.api('drive/files/update', {
fileId: file.id,
folderId: this.folder.id
});
}
//#endregion
//#region ドライブのフォルダ
const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
if (driveFolder != null && driveFolder != '') {
const folder = JSON.parse(driveFolder);
// 移動先が自分自身ならreject
if (folder.id == this.folder.id) return;
this.browser.removeFolder(folder.id);
os.api('drive/folders/update', {
folderId: folder.id,
parentId: this.folder.id
}).then(() => {
// noop
}).catch(err => {
switch (err) {
case 'detected-circular-definition':
os.alert({
title: this.$ts.unableToProcess,
text: this.$ts.circularReferenceFolder
});
break;
default:
os.alert({
type: 'error',
text: this.$ts.somethingHappened
});
}
});
}
//#endregion
},
onDragstart(e) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(this.folder));
this.isDragging = true;
// 親ブラウザに対して、ドラッグが開始されたフラグを立てる
// (=あなたの子供が、ドラッグを開始しましたよ)
this.browser.isDragSource = true;
},
onDragend() {
this.isDragging = false;
this.browser.isDragSource = false;
},
go() {
this.browser.move(this.folder.id);
},
newWindow() {
this.browser.newWindow(this.folder);
},
rename() {
os.inputText({
title: this.$ts.renameFolder,
placeholder: this.$ts.inputNewFolderName,
default: this.folder.name
}).then(({ canceled, result: name }) => {
if (canceled) return;
os.api('drive/folders/update', {
folderId: this.folder.id,
name: name
});
});
},
deleteFolder() {
os.api('drive/folders/delete', {
folderId: this.folder.id
}).then(() => {
if (this.$store.state.uploadFolder === this.folder.id) {
this.$store.set('uploadFolder', null);
}
}).catch(err => {
switch(err.id) {
case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
os.alert({
type: 'error',
title: this.$ts.unableToDelete,
text: this.$ts.hasChildFilesOrFolders
});
break;
default:
os.alert({
type: 'error',
text: this.$ts.unableToDelete
});
}
});
},
setAsUploadFolder() {
this.$store.set('uploadFolder', this.folder.id);
},
onContextmenu(e) {
os.contextMenu([{
text: this.$ts.openInWindow,
icon: 'fas fa-window-restore',
action: () => {
os.popup(import('./drive-window.vue'), {
initialFolder: this.folder
}, {
}, 'closed');
}
}, null, {
text: this.$ts.rename,
icon: 'fas fa-i-cursor',
action: this.rename
}, null, {
text: this.$ts.delete,
icon: 'fas fa-trash-alt',
danger: true,
action: this.deleteFolder
}], e);
},
}
const props = withDefaults(defineProps<{
folder: Misskey.entities.DriveFolder;
isSelected?: boolean;
selectMode?: boolean;
}>(), {
isSelected: false,
selectMode: false,
});
const emit = defineEmits<{
(ev: 'chosen', v: Misskey.entities.DriveFolder): void;
(ev: 'move', v: Misskey.entities.DriveFolder): void;
(ev: 'upload', file: File, folder: Misskey.entities.DriveFolder);
(ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
(ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
(ev: 'dragstart'): void;
(ev: 'dragend'): void;
}>();
const hover = ref(false);
const draghover = ref(false);
const isDragging = ref(false);
const title = computed(() => props.folder.name);
function checkboxClicked() {
emit('chosen', props.folder);
}
function onClick() {
emit('move', props.folder);
}
function onMouseover() {
hover.value = true;
}
function onMouseout() {
hover.value = false
}
function onDragover(ev: DragEvent) {
if (!ev.dataTransfer) return;
// 自分自身がドラッグされている場合
if (isDragging.value) {
// 自分自身にはドロップさせない
ev.dataTransfer.dropEffect = 'none';
return;
}
const isFile = ev.dataTransfer.items[0].kind == 'file';
const isDriveFile = ev.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
const isDriveFolder = ev.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_;
if (isFile || isDriveFile || isDriveFolder) {
ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
} else {
ev.dataTransfer.dropEffect = 'none';
}
}
function onDragenter() {
if (!isDragging.value) draghover.value = true;
}
function onDragleave() {
draghover.value = false;
}
function onDrop(ev: DragEvent) {
draghover.value = false;
if (!ev.dataTransfer) return;
// ファイルだったら
if (ev.dataTransfer.files.length > 0) {
for (const file of Array.from(ev.dataTransfer.files)) {
emit('upload', file, props.folder);
}
return;
}
//#region ドライブのファイル
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile != '') {
const file = JSON.parse(driveFile);
emit('removeFile', file.id);
os.api('drive/files/update', {
fileId: file.id,
folderId: props.folder.id
});
}
//#endregion
//#region ドライブのフォルダ
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
if (driveFolder != null && driveFolder != '') {
const folder = JSON.parse(driveFolder);
// 移動先が自分自身ならreject
if (folder.id == props.folder.id) return;
emit('removeFolder', folder.id);
os.api('drive/folders/update', {
folderId: folder.id,
parentId: props.folder.id
}).then(() => {
// noop
}).catch(err => {
switch (err) {
case 'detected-circular-definition':
os.alert({
title: i18n.locale.unableToProcess,
text: i18n.locale.circularReferenceFolder
});
break;
default:
os.alert({
type: 'error',
text: i18n.locale.somethingHappened
});
}
});
}
//#endregion
}
function onDragstart(ev: DragEvent) {
if (!ev.dataTransfer) return;
ev.dataTransfer.effectAllowed = 'move';
ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(props.folder));
isDragging.value = true;
// 親ブラウザに対して、ドラッグが開始されたフラグを立てる
// (=あなたの子供が、ドラッグを開始しましたよ)
emit('dragstart');
}
function onDragend() {
isDragging.value = false;
emit('dragend');
}
function go() {
emit('move', props.folder.id);
}
function rename() {
os.inputText({
title: i18n.locale.renameFolder,
placeholder: i18n.locale.inputNewFolderName,
default: props.folder.name
}).then(({ canceled, result: name }) => {
if (canceled) return;
os.api('drive/folders/update', {
folderId: props.folder.id,
name: name
});
});
}
function deleteFolder() {
os.api('drive/folders/delete', {
folderId: props.folder.id
}).then(() => {
if (defaultStore.state.uploadFolder === props.folder.id) {
defaultStore.set('uploadFolder', null);
}
}).catch(err => {
switch(err.id) {
case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
os.alert({
type: 'error',
title: i18n.locale.unableToDelete,
text: i18n.locale.hasChildFilesOrFolders
});
break;
default:
os.alert({
type: 'error',
text: i18n.locale.unableToDelete
});
}
});
}
function setAsUploadFolder() {
defaultStore.set('uploadFolder', props.folder.id);
}
function onContextmenu(ev: MouseEvent) {
os.contextMenu([{
text: i18n.locale.openInWindow,
icon: 'fas fa-window-restore',
action: () => {
os.popup(import('./drive-window.vue'), {
initialFolder: props.folder
}, {
}, 'closed');
}
}, null, {
text: i18n.locale.rename,
icon: 'fas fa-i-cursor',
action: rename,
}, null, {
text: i18n.locale.delete,
icon: 'fas fa-trash-alt',
danger: true,
action: deleteFolder,
}], ev);
}
</script>
<style lang="scss" scoped>

View File

@ -8,114 +8,111 @@
@drop.stop="onDrop"
>
<i v-if="folder == null" class="fas fa-cloud"></i>
<span>{{ folder == null ? $ts.drive : folder.name }}</span>
<span>{{ folder == null ? i18n.locale.drive : folder.name }}</span>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os';
import { i18n } from '@/i18n';
export default defineComponent({
props: {
folder: {
type: Object,
required: false,
}
},
const props = defineProps<{
folder?: Misskey.entities.DriveFolder;
parentFolder: Misskey.entities.DriveFolder | null;
}>();
data() {
return {
hover: false,
draghover: false,
};
},
const emit = defineEmits<{
(e: 'move', v?: Misskey.entities.DriveFolder): void;
(e: 'upload', file: File, folder?: Misskey.entities.DriveFolder | null): void;
(e: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
(e: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
}>();
computed: {
browser(): any {
return this.$parent;
}
},
const hover = ref(false);
const draghover = ref(false);
methods: {
onClick() {
this.browser.move(this.folder);
},
function onClick() {
emit('move', props.folder);
}
onMouseover() {
this.hover = true;
},
function onMouseover() {
hover.value = true;
}
onMouseout() {
this.hover = false;
},
function onMouseout() {
hover.value = false;
}
onDragover(e) {
// このフォルダがルートかつカレントディレクトリならドロップ禁止
if (this.folder == null && this.browser.folder == null) {
e.dataTransfer.dropEffect = 'none';
}
function onDragover(e: DragEvent) {
if (!e.dataTransfer) return;
const isFile = e.dataTransfer.items[0].kind == 'file';
const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_;
if (isFile || isDriveFile || isDriveFolder) {
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
} else {
e.dataTransfer.dropEffect = 'none';
}
return false;
},
onDragenter() {
if (this.folder || this.browser.folder) this.draghover = true;
},
onDragleave() {
if (this.folder || this.browser.folder) this.draghover = false;
},
onDrop(e) {
this.draghover = false;
// ファイルだったら
if (e.dataTransfer.files.length > 0) {
for (const file of Array.from(e.dataTransfer.files)) {
this.browser.upload(file, this.folder);
}
return;
}
//#region ドライブのファイル
const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile != '') {
const file = JSON.parse(driveFile);
this.browser.removeFile(file.id);
os.api('drive/files/update', {
fileId: file.id,
folderId: this.folder ? this.folder.id : null
});
}
//#endregion
//#region ドライブのフォルダ
const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
if (driveFolder != null && driveFolder != '') {
const folder = JSON.parse(driveFolder);
// 移動先が自分自身ならreject
if (this.folder && folder.id == this.folder.id) return;
this.browser.removeFolder(folder.id);
os.api('drive/folders/update', {
folderId: folder.id,
parentId: this.folder ? this.folder.id : null
});
}
//#endregion
}
// このフォルダがルートかつカレントディレクトリならドロップ禁止
if (props.folder == null && props.parentFolder == null) {
e.dataTransfer.dropEffect = 'none';
}
});
const isFile = e.dataTransfer.items[0].kind == 'file';
const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
const isDriveFolder = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FOLDER_;
if (isFile || isDriveFile || isDriveFolder) {
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
} else {
e.dataTransfer.dropEffect = 'none';
}
return false;
}
function onDragenter() {
if (props.folder || props.parentFolder) draghover.value = true;
}
function onDragleave() {
if (props.folder || props.parentFolder) draghover.value = false;
}
function onDrop(e: DragEvent) {
draghover.value = false;
if (!e.dataTransfer) return;
// ファイルだったら
if (e.dataTransfer.files.length > 0) {
for (const file of Array.from(e.dataTransfer.files)) {
emit('upload', file, props.folder);
}
return;
}
//#region ドライブのファイル
const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile != '') {
const file = JSON.parse(driveFile);
emit('removeFile', file.id);
os.api('drive/files/update', {
fileId: file.id,
folderId: props.folder ? props.folder.id : null
});
}
//#endregion
//#region ドライブのフォルダ
const driveFolder = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
if (driveFolder != null && driveFolder != '') {
const folder = JSON.parse(driveFolder);
// 移動先が自分自身ならreject
if (props.folder && folder.id == props.folder.id) return;
emit('removeFolder', folder.id);
os.api('drive/folders/update', {
folderId: folder.id,
parentId: props.folder ? props.folder.id : null
});
}
//#endregion
}
</script>
<style lang="scss" scoped>

File diff suppressed because it is too large Load Diff

View File

@ -1,58 +1,65 @@
<template>
<MkModal ref="modal" v-slot="{ type, maxHeight }" :z-priority="'middle'" :prefer-type="asReactionPicker && $store.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'" :transparent-bg="true" :manual-showing="manualShowing" :src="src" @click="$refs.modal.close()" @opening="opening" @close="$emit('close')" @closed="$emit('closed')">
<MkEmojiPicker ref="picker" class="ryghynhb _popup _shadow" :class="{ drawer: type === 'drawer' }" :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" :as-drawer="type === 'drawer'" :max-height="maxHeight" @chosen="chosen"/>
<MkModal
ref="modal"
v-slot="{ type, maxHeight }"
:z-priority="'middle'"
:prefer-type="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'"
:transparent-bg="true"
:manual-showing="manualShowing"
:src="src"
@click="modal?.close()"
@opening="opening"
@close="emit('close')"
@closed="emit('closed')"
>
<MkEmojiPicker
ref="picker"
class="ryghynhb _popup _shadow"
:class="{ drawer: type === 'drawer' }"
:show-pinned="showPinned"
:as-reaction-picker="asReactionPicker"
:as-drawer="type === 'drawer'"
:max-height="maxHeight"
@chosen="chosen"
/>
</MkModal>
</template>
<script lang="ts">
import { defineComponent, markRaw } from 'vue';
<script lang="ts" setup>
import { ref } from 'vue';
import MkModal from '@/components/ui/modal.vue';
import MkEmojiPicker from '@/components/emoji-picker.vue';
import { defaultStore } from '@/store';
export default defineComponent({
components: {
MkModal,
MkEmojiPicker,
},
props: {
manualShowing: {
type: Boolean,
required: false,
default: null,
},
src: {
required: false
},
showPinned: {
required: false,
default: true
},
asReactionPicker: {
required: false
},
},
emits: ['done', 'close', 'closed'],
data() {
return {
};
},
methods: {
chosen(emoji: any) {
this.$emit('done', emoji);
this.$refs.modal.close();
},
opening() {
this.$refs.picker.reset();
this.$refs.picker.focus();
}
}
withDefaults(defineProps<{
manualShowing?: boolean;
src?: HTMLElement;
showPinned?: boolean;
asReactionPicker?: boolean;
}>(), {
manualShowing: false,
showPinned: true,
asReactionPicker: false,
});
const emit = defineEmits<{
(e: 'done', v: any): void;
(e: 'close'): void;
(e: 'closed'): void;
}>();
const modal = ref<InstanceType<typeof MkModal>>();
const picker = ref<InstanceType<typeof MkEmojiPicker>>();
function chosen(emoji: any) {
emit('done', emoji);
modal.value?.close();
}
function opening() {
picker.value?.reset();
picker.value?.focus();
}
</script>
<style lang="scss" scoped>

View File

@ -5,50 +5,33 @@
:can-resize="false"
:mini="true"
:front="true"
@closed="$emit('closed')"
@closed="emit('closed')"
>
<MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen"/>
</MkWindow>
</template>
<script lang="ts">
import { defineComponent, markRaw } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
import MkWindow from '@/components/ui/window.vue';
import MkEmojiPicker from '@/components/emoji-picker.vue';
export default defineComponent({
components: {
MkWindow,
MkEmojiPicker,
},
props: {
src: {
required: false
},
showPinned: {
required: false,
default: true
},
asReactionPicker: {
required: false
},
},
emits: ['chosen', 'closed'],
data() {
return {
};
},
methods: {
chosen(emoji: any) {
this.$emit('chosen', emoji);
},
}
withDefaults(defineProps<{
src?: HTMLElement;
showPinned?: boolean;
asReactionPicker?: boolean;
}>(), {
showPinned: true,
});
const emit = defineEmits<{
(e: 'chosen', v: any): void;
(e: 'closed'): void;
}>();
function chosen(emoji: any) {
emit('chosen', emoji);
}
</script>
<style lang="scss" scoped>

View File

@ -7,7 +7,7 @@
<button v-for="emoji in emojis"
:key="emoji"
class="_button"
@click="chosen(emoji, $event)"
@click="emit('chosen', emoji, $event)"
>
<MkEmoji :emoji="emoji" :normal="true"/>
</button>
@ -15,35 +15,19 @@
</section>
</template>
<script lang="ts">
import { defineComponent, markRaw } from 'vue';
import { getStaticImageUrl } from '@/scripts/get-static-image-url';
<script lang="ts" setup>
import { ref } from 'vue';
export default defineComponent({
props: {
emojis: {
required: true,
},
initialShown: {
required: false
}
},
const props = defineProps<{
emojis: string[];
initialShown?: boolean;
}>();
emits: ['chosen'],
const emit = defineEmits<{
(e: 'chosen', v: string, ev: MouseEvent): void;
}>();
data() {
return {
getStaticImageUrl,
shown: this.initialShown,
};
},
methods: {
chosen(emoji: any, ev) {
this.$parent.chosen(emoji, ev);
},
}
});
const shown = ref(!!props.initialShown);
</script>
<style lang="scss" scoped>

View File

@ -1,18 +1,18 @@
<template>
<div class="omfetrab" :class="['w' + width, 'h' + height, { big, asDrawer }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : null }">
<input ref="search" v-model.trim="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="$ts.search" @paste.stop="paste" @keyup.enter="done()">
<div class="omfetrab" :class="['w' + width, 'h' + height, { big, asDrawer }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }">
<input ref="search" v-model.trim="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.locale.search" @paste.stop="paste" @keyup.enter="done()">
<div ref="emojis" class="emojis">
<section class="result">
<div v-if="searchResultCustom.length > 0">
<button v-for="emoji in searchResultCustom"
:key="emoji"
:key="emoji.id"
class="_button"
:title="emoji.name"
tabindex="0"
@click="chosen(emoji, $event)"
>
<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>
<img v-else :src="$store.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
<!--<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>-->
<img :src="disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
</button>
</div>
<div v-if="searchResultUnicode.length > 0">
@ -43,9 +43,9 @@
</section>
<section>
<header class="_acrylic"><i class="far fa-clock fa-fw"></i> {{ $ts.recentUsed }}</header>
<header class="_acrylic"><i class="far fa-clock fa-fw"></i> {{ i18n.locale.recentUsed }}</header>
<div>
<button v-for="emoji in $store.state.recentlyUsedEmojis"
<button v-for="emoji in recentlyUsedEmojis"
:key="emoji"
class="_button"
@click="chosen(emoji, $event)"
@ -56,12 +56,12 @@
</section>
</div>
<div>
<header class="_acrylic">{{ $ts.customEmojis }}</header>
<XSection v-for="category in customEmojiCategories" :key="'custom:' + category" :initial-shown="false" :emojis="customEmojis.filter(e => e.category === category).map(e => ':' + e.name + ':')">{{ category || $ts.other }}</XSection>
<header class="_acrylic">{{ i18n.locale.customEmojis }}</header>
<XSection v-for="category in customEmojiCategories" :key="'custom:' + category" :initial-shown="false" :emojis="customEmojis.filter(e => e.category === category).map(e => ':' + e.name + ':')" @chosen="chosen">{{ category || i18n.locale.other }}</XSection>
</div>
<div>
<header class="_acrylic">{{ $ts.emoji }}</header>
<XSection v-for="category in categories" :emojis="emojilist.filter(e => e.category === category).map(e => e.char)">{{ category }}</XSection>
<header class="_acrylic">{{ i18n.locale.emoji }}</header>
<XSection v-for="category in categories" :emojis="emojilist.filter(e => e.category === category).map(e => e.char)" @chosen="chosen">{{ category }}</XSection>
</div>
</div>
<div class="tabs">
@ -73,277 +73,272 @@
</div>
</template>
<script lang="ts">
import { defineComponent, markRaw } from 'vue';
import { emojilist } from '@/scripts/emojilist';
<script lang="ts" setup>
import { ref, computed, watch, onMounted } from 'vue';
import * as Misskey from 'misskey-js';
import { emojilist, UnicodeEmojiDef, unicodeEmojiCategories as categories } from '@/scripts/emojilist';
import { getStaticImageUrl } from '@/scripts/get-static-image-url';
import Ripple from '@/components/ripple.vue';
import * as os from '@/os';
import { isTouchUsing } from '@/scripts/touch';
import { isMobile } from '@/scripts/is-mobile';
import { emojiCategories } from '@/instance';
import { emojiCategories, instance } from '@/instance';
import XSection from './emoji-picker.section.vue';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
export default defineComponent({
components: {
XSection
},
const props = withDefaults(defineProps<{
showPinned?: boolean;
asReactionPicker?: boolean;
maxHeight?: number;
asDrawer?: boolean;
}>(), {
showPinned: true,
});
props: {
showPinned: {
required: false,
default: true,
},
asReactionPicker: {
required: false,
},
maxHeight: {
type: Number,
required: false,
},
asDrawer: {
type: Boolean,
required: false
},
},
const emit = defineEmits<{
(e: 'chosen', v: string): void;
}>();
emits: ['chosen'],
const search = ref<HTMLInputElement>();
const emojis = ref<HTMLDivElement>();
data() {
return {
emojilist: markRaw(emojilist),
getStaticImageUrl,
pinned: this.$store.reactiveState.reactions,
width: this.asReactionPicker ? this.$store.state.reactionPickerWidth : 3,
height: this.asReactionPicker ? this.$store.state.reactionPickerHeight : 2,
big: this.asReactionPicker ? isTouchUsing : false,
customEmojiCategories: emojiCategories,
customEmojis: this.$instance.emojis,
q: null,
searchResultCustom: [],
searchResultUnicode: [],
tab: 'index',
categories: ['face', 'people', 'animals_and_nature', 'food_and_drink', 'activity', 'travel_and_places', 'objects', 'symbols', 'flags'],
};
},
const {
reactions: pinned,
reactionPickerWidth,
reactionPickerHeight,
disableShowingAnimatedImages,
recentlyUsedEmojis,
} = defaultStore.reactiveState;
watch: {
q() {
this.$refs.emojis.scrollTop = 0;
const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3);
const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2);
const big = props.asReactionPicker ? isTouchUsing : false;
const customEmojiCategories = emojiCategories;
const customEmojis = instance.emojis;
const q = ref<string | null>(null);
const searchResultCustom = ref<Misskey.entities.CustomEmoji[]>([]);
const searchResultUnicode = ref<UnicodeEmojiDef[]>([]);
const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index');
if (this.q == null || this.q === '') {
this.searchResultCustom = [];
this.searchResultUnicode = [];
return;
}
watch(q, () => {
if (emojis.value) emojis.value.scrollTop = 0;
const q = this.q.replace(/:/g, '');
const searchCustom = () => {
const max = 8;
const emojis = this.customEmojis;
const matches = new Set();
const exactMatch = emojis.find(e => e.name === q);
if (exactMatch) matches.add(exactMatch);
if (q.includes(' ')) { // AND検索
const keywords = q.split(' ');
// 名前にキーワードが含まれている
for (const emoji of emojis) {
if (keywords.every(keyword => emoji.name.includes(keyword))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
// 名前またはエイリアスにキーワードが含まれている
for (const emoji of emojis) {
if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.aliases.some(alias => alias.includes(keyword)))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
} else {
for (const emoji of emojis) {
if (emoji.name.startsWith(q)) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.aliases.some(alias => alias.startsWith(q))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.name.includes(q)) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.aliases.some(alias => alias.includes(q))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
}
return matches;
};
const searchUnicode = () => {
const max = 8;
const emojis = this.emojilist;
const matches = new Set();
const exactMatch = emojis.find(e => e.name === q);
if (exactMatch) matches.add(exactMatch);
if (q.includes(' ')) { // AND検索
const keywords = q.split(' ');
// 名前にキーワードが含まれている
for (const emoji of emojis) {
if (keywords.every(keyword => emoji.name.includes(keyword))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
// 名前またはエイリアスにキーワードが含まれている
for (const emoji of emojis) {
if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.keywords.some(alias => alias.includes(keyword)))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
} else {
for (const emoji of emojis) {
if (emoji.name.startsWith(q)) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.keywords.some(keyword => keyword.startsWith(q))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.name.includes(q)) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.keywords.some(keyword => keyword.includes(q))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
}
return matches;
};
this.searchResultCustom = Array.from(searchCustom());
this.searchResultUnicode = Array.from(searchUnicode());
}
},
mounted() {
this.focus();
},
methods: {
focus() {
if (!isMobile && !isTouchUsing) {
this.$refs.search.focus({
preventScroll: true
});
}
},
reset() {
this.$refs.emojis.scrollTop = 0;
this.q = '';
},
getKey(emoji: any) {
return typeof emoji === 'string' ? emoji : (emoji.char || `:${emoji.name}:`);
},
chosen(emoji: any, ev) {
if (ev) {
const el = ev.currentTarget || ev.target;
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(Ripple, { x, y }, {}, 'end');
}
const key = this.getKey(emoji);
this.$emit('chosen', key);
// 最近使った絵文字更新
if (!this.pinned.includes(key)) {
let recents = this.$store.state.recentlyUsedEmojis;
recents = recents.filter((e: any) => e !== key);
recents.unshift(key);
this.$store.set('recentlyUsedEmojis', recents.splice(0, 32));
}
},
paste(event) {
const paste = (event.clipboardData || window.clipboardData).getData('text');
if (this.done(paste)) {
event.preventDefault();
}
},
done(query) {
if (query == null) query = this.q;
if (query == null) return;
const q = query.replace(/:/g, '');
const exactMatchCustom = this.customEmojis.find(e => e.name === q);
if (exactMatchCustom) {
this.chosen(exactMatchCustom);
return true;
}
const exactMatchUnicode = this.emojilist.find(e => e.char === q || e.name === q);
if (exactMatchUnicode) {
this.chosen(exactMatchUnicode);
return true;
}
if (this.searchResultCustom.length > 0) {
this.chosen(this.searchResultCustom[0]);
return true;
}
if (this.searchResultUnicode.length > 0) {
this.chosen(this.searchResultUnicode[0]);
return true;
}
},
if (q.value == null || q.value === '') {
searchResultCustom.value = [];
searchResultUnicode.value = [];
return;
}
const newQ = q.value.replace(/:/g, '');
const searchCustom = () => {
const max = 8;
const emojis = customEmojis;
const matches = new Set<Misskey.entities.CustomEmoji>();
const exactMatch = emojis.find(e => e.name === newQ);
if (exactMatch) matches.add(exactMatch);
if (newQ.includes(' ')) { // AND検索
const keywords = newQ.split(' ');
// 名前にキーワードが含まれている
for (const emoji of emojis) {
if (keywords.every(keyword => emoji.name.includes(keyword))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
// 名前またはエイリアスにキーワードが含まれている
for (const emoji of emojis) {
if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.aliases.some(alias => alias.includes(keyword)))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
} else {
for (const emoji of emojis) {
if (emoji.name.startsWith(newQ)) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.aliases.some(alias => alias.startsWith(newQ))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.name.includes(newQ)) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.aliases.some(alias => alias.includes(newQ))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
}
return matches;
};
const searchUnicode = () => {
const max = 8;
const emojis = emojilist;
const matches = new Set<UnicodeEmojiDef>();
const exactMatch = emojis.find(e => e.name === newQ);
if (exactMatch) matches.add(exactMatch);
if (newQ.includes(' ')) { // AND検索
const keywords = newQ.split(' ');
// 名前にキーワードが含まれている
for (const emoji of emojis) {
if (keywords.every(keyword => emoji.name.includes(keyword))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
// 名前またはエイリアスにキーワードが含まれている
for (const emoji of emojis) {
if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.keywords.some(alias => alias.includes(keyword)))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
} else {
for (const emoji of emojis) {
if (emoji.name.startsWith(newQ)) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.keywords.some(keyword => keyword.startsWith(newQ))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.name.includes(newQ)) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
if (matches.size >= max) return matches;
for (const emoji of emojis) {
if (emoji.keywords.some(keyword => keyword.includes(newQ))) {
matches.add(emoji);
if (matches.size >= max) break;
}
}
}
return matches;
};
searchResultCustom.value = Array.from(searchCustom());
searchResultUnicode.value = Array.from(searchUnicode());
});
function focus() {
if (!isMobile && !isTouchUsing) {
search.value?.focus({
preventScroll: true
});
}
}
function reset() {
if (emojis.value) emojis.value.scrollTop = 0;
q.value = '';
}
function getKey(emoji: string | Misskey.entities.CustomEmoji | UnicodeEmojiDef): string {
return typeof emoji === 'string' ? emoji : (emoji.char || `:${emoji.name}:`);
}
function chosen(emoji: any, ev?: MouseEvent) {
const el = ev && (ev.currentTarget || ev.target) as HTMLElement | null | undefined;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(Ripple, { x, y }, {}, 'end');
}
const key = getKey(emoji);
emit('chosen', key);
// 最近使った絵文字更新
if (!pinned.value.includes(key)) {
let recents = defaultStore.state.recentlyUsedEmojis;
recents = recents.filter((e: any) => e !== key);
recents.unshift(key);
defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32));
}
}
function paste(event: ClipboardEvent) {
const paste = (event.clipboardData || window.clipboardData).getData('text');
if (done(paste)) {
event.preventDefault();
}
}
function done(query?: any): boolean | void {
if (query == null) query = q.value;
if (query == null || typeof query !== 'string') return;
const q2 = query.replace(/:/g, '');
const exactMatchCustom = customEmojis.find(e => e.name === q2);
if (exactMatchCustom) {
chosen(exactMatchCustom);
return true;
}
const exactMatchUnicode = emojilist.find(e => e.char === q2 || e.name === q2);
if (exactMatchUnicode) {
chosen(exactMatchUnicode);
return true;
}
if (searchResultCustom.value.length > 0) {
chosen(searchResultCustom.value[0]);
return true;
}
if (searchResultUnicode.value.length > 0) {
chosen(searchResultUnicode.value[0]);
return true;
}
}
onMounted(() => {
focus();
});
defineExpose({
focus,
reset,
});
</script>

View File

@ -2,25 +2,15 @@
<div v-if="meta" class="xfbouadm" :style="{ backgroundImage: `url(${ meta.backgroundImageUrl })` }"></div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os';
export default defineComponent({
components: {
},
const meta = ref<Misskey.entities.DetailedInstanceMetadata>();
data() {
return {
meta: null,
};
},
created() {
os.api('meta', { detail: true }).then(meta => {
this.meta = meta;
});
},
os.api('meta', { detail: true }).then(gotMeta => {
meta.value = gotMeta;
});
</script>

View File

@ -4,25 +4,12 @@
</span>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import * as os from '@/os';
<script lang="ts" setup>
import { computed } from 'vue';
export default defineComponent({
props: {
type: {
type: String,
required: true,
}
},
data() {
return {
};
},
computed: {
kind(): string {
return this.type.split('/')[0];
}
}
});
const props = defineProps<{
type: string;
}>();
const kind = computed(() => props.type.split('/')[0]);
</script>

View File

@ -6,128 +6,110 @@
>
<template v-if="!wait">
<template v-if="hasPendingFollowRequestFromYou && user.isLocked">
<span v-if="full">{{ $ts.followRequestPending }}</span><i class="fas fa-hourglass-half"></i>
<span v-if="full">{{ i18n.locale.followRequestPending }}</span><i class="fas fa-hourglass-half"></i>
</template>
<template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked"> <!-- つまりリモートフォローの場合 -->
<span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse"></i>
<span v-if="full">{{ i18n.locale.processing }}</span><i class="fas fa-spinner fa-pulse"></i>
</template>
<template v-else-if="isFollowing">
<span v-if="full">{{ $ts.unfollow }}</span><i class="fas fa-minus"></i>
<span v-if="full">{{ i18n.locale.unfollow }}</span><i class="fas fa-minus"></i>
</template>
<template v-else-if="!isFollowing && user.isLocked">
<span v-if="full">{{ $ts.followRequest }}</span><i class="fas fa-plus"></i>
<span v-if="full">{{ i18n.locale.followRequest }}</span><i class="fas fa-plus"></i>
</template>
<template v-else-if="!isFollowing && !user.isLocked">
<span v-if="full">{{ $ts.follow }}</span><i class="fas fa-plus"></i>
<span v-if="full">{{ i18n.locale.follow }}</span><i class="fas fa-plus"></i>
</template>
</template>
<template v-else>
<span v-if="full">{{ $ts.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i>
<span v-if="full">{{ i18n.locale.processing }}</span><i class="fas fa-spinner fa-pulse fa-fw"></i>
</template>
</button>
</template>
<script lang="ts">
import { defineComponent, markRaw } from 'vue';
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os';
import { stream } from '@/stream';
import { i18n } from '@/i18n';
export default defineComponent({
props: {
user: {
type: Object,
required: true
},
full: {
type: Boolean,
required: false,
default: false,
},
large: {
type: Boolean,
required: false,
default: false,
},
},
const props = withDefaults(defineProps<{
user: Misskey.entities.UserDetailed,
full?: boolean,
large?: boolean,
}>(), {
full: false,
large: false,
});
data() {
return {
isFollowing: this.user.isFollowing,
hasPendingFollowRequestFromYou: this.user.hasPendingFollowRequestFromYou,
wait: false,
connection: null,
};
},
const isFollowing = ref(props.user.isFollowing);
const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFromYou);
const wait = ref(false);
const connection = stream.useChannel('main');
created() {
// 渡されたユーザー情報が不完全な場合
if (this.user.isFollowing == null) {
os.api('users/show', {
userId: this.user.id
}).then(u => {
this.isFollowing = u.isFollowing;
this.hasPendingFollowRequestFromYou = u.hasPendingFollowRequestFromYou;
});
}
},
if (props.user.isFollowing == null) {
os.api('users/show', {
userId: props.user.id
}).then(u => {
isFollowing.value = u.isFollowing;
hasPendingFollowRequestFromYou.value = u.hasPendingFollowRequestFromYou;
});
}
mounted() {
this.connection = markRaw(os.stream.useChannel('main'));
this.connection.on('follow', this.onFollowChange);
this.connection.on('unfollow', this.onFollowChange);
},
beforeUnmount() {
this.connection.dispose();
},
methods: {
onFollowChange(user) {
if (user.id == this.user.id) {
this.isFollowing = user.isFollowing;
this.hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou;
}
},
async onClick() {
this.wait = true;
try {
if (this.isFollowing) {
const { canceled } = await os.confirm({
type: 'warning',
text: this.$t('unfollowConfirm', { name: this.user.name || this.user.username }),
});
if (canceled) return;
await os.api('following/delete', {
userId: this.user.id
});
} else {
if (this.hasPendingFollowRequestFromYou) {
await os.api('following/requests/cancel', {
userId: this.user.id
});
} else if (this.user.isLocked) {
await os.api('following/create', {
userId: this.user.id
});
this.hasPendingFollowRequestFromYou = true;
} else {
await os.api('following/create', {
userId: this.user.id
});
this.hasPendingFollowRequestFromYou = true;
}
}
} catch (e) {
console.error(e);
} finally {
this.wait = false;
}
}
function onFollowChange(user: Misskey.entities.UserDetailed) {
if (user.id == props.user.id) {
isFollowing.value = user.isFollowing;
hasPendingFollowRequestFromYou.value = user.hasPendingFollowRequestFromYou;
}
}
async function onClick() {
wait.value = true;
try {
if (isFollowing.value) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('unfollowConfirm', { name: props.user.name || props.user.username }),
});
if (canceled) return;
await os.api('following/delete', {
userId: props.user.id
});
} else {
if (hasPendingFollowRequestFromYou.value) {
await os.api('following/requests/cancel', {
userId: props.user.id
});
} else if (props.user.isLocked) {
await os.api('following/create', {
userId: props.user.id
});
hasPendingFollowRequestFromYou.value = true;
} else {
await os.api('following/create', {
userId: props.user.id
});
hasPendingFollowRequestFromYou.value = true;
}
}
} catch (e) {
console.error(e);
} finally {
wait.value = false;
}
}
onMounted(() => {
connection.on('follow', onFollowChange);
connection.on('unfollow', onFollowChange);
});
onBeforeUnmount(() => {
connection.dispose();
});
</script>

View File

@ -2,72 +2,64 @@
<XModalWindow ref="dialog"
:width="370"
:height="400"
@close="$refs.dialog.close()"
@closed="$emit('closed')"
@close="dialog.close()"
@closed="emit('closed')"
>
<template #header>{{ $ts.forgotPassword }}</template>
<template #header>{{ i18n.locale.forgotPassword }}</template>
<form v-if="$instance.enableEmail" class="bafeceda" @submit.prevent="onSubmit">
<form v-if="instance.enableEmail" class="bafeceda" @submit.prevent="onSubmit">
<div class="main _formRoot">
<MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required>
<template #label>{{ $ts.username }}</template>
<template #label>{{ i18n.locale.username }}</template>
<template #prefix>@</template>
</MkInput>
<MkInput v-model="email" class="_formBlock" type="email" spellcheck="false" required>
<template #label>{{ $ts.emailAddress }}</template>
<template #caption>{{ $ts._forgotPassword.enterEmail }}</template>
<template #label>{{ i18n.locale.emailAddress }}</template>
<template #caption>{{ i18n.locale._forgotPassword.enterEmail }}</template>
</MkInput>
<MkButton class="_formBlock" type="submit" :disabled="processing" primary style="margin: 0 auto;">{{ $ts.send }}</MkButton>
<MkButton class="_formBlock" type="submit" :disabled="processing" primary style="margin: 0 auto;">{{ i18n.locale.send }}</MkButton>
</div>
<div class="sub">
<MkA to="/about" class="_link">{{ $ts._forgotPassword.ifNoEmail }}</MkA>
<MkA to="/about" class="_link">{{ i18n.locale._forgotPassword.ifNoEmail }}</MkA>
</div>
</form>
<div v-else>
{{ $ts._forgotPassword.contactAdmin }}
<div v-else class="bafecedb">
{{ i18n.locale._forgotPassword.contactAdmin }}
</div>
</XModalWindow>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
import XModalWindow from '@/components/ui/modal-window.vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import * as os from '@/os';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
export default defineComponent({
components: {
XModalWindow,
MkButton,
MkInput,
},
const emit = defineEmits<{
(e: 'done'): void;
(e: 'closed'): void;
}>();
emits: ['done', 'closed'],
let dialog: InstanceType<typeof XModalWindow> = $ref();
data() {
return {
username: '',
email: '',
processing: false,
};
},
let username = $ref('');
let email = $ref('');
let processing = $ref(false);
methods: {
async onSubmit() {
this.processing = true;
await os.apiWithDialog('request-reset-password', {
username: this.username,
email: this.email,
});
this.$emit('done');
this.$refs.dialog.close();
}
}
});
async function onSubmit() {
processing = true;
await os.apiWithDialog('request-reset-password', {
username,
email,
});
emit('done');
dialog.close();
}
</script>
<style lang="scss" scoped>
@ -81,4 +73,8 @@ export default defineComponent({
padding: 24px;
}
}
.bafecedb {
padding: 24px;
}
</style>

View File

@ -0,0 +1,107 @@
<template>
<div class="dwzlatin" :class="{ opened }">
<div class="header _button" @click="toggle">
<span class="icon"><slot name="icon"></slot></span>
<span class="text"><slot name="label"></slot></span>
<span class="right">
<span class="text"><slot name="suffix"></slot></span>
<i v-if="opened" class="fas fa-angle-up icon"></i>
<i v-else class="fas fa-angle-down icon"></i>
</span>
</div>
<keep-alive>
<div v-if="openedAtLeastOnce" v-show="opened" class="body">
<MkSpacer :margin-min="14" :margin-max="22">
<slot></slot>
</MkSpacer>
</div>
</keep-alive>
</div>
</template>
<script lang="ts" setup>
const props = withDefaults(defineProps<{
defaultOpen: boolean;
}>(), {
defaultOpen: false,
})
let opened = $ref(props.defaultOpen);
let openedAtLeastOnce = $ref(props.defaultOpen);
const toggle = () => {
opened = !opened;
if (opened) {
openedAtLeastOnce = true;
}
};
</script>
<style lang="scss" scoped>
.dwzlatin {
display: block;
> .header {
display: flex;
align-items: center;
width: 100%;
box-sizing: border-box;
padding: 12px 14px 12px 14px;
background: var(--buttonBg);
border-radius: 6px;
&:hover {
text-decoration: none;
background: var(--buttonHoverBg);
}
&.active {
color: var(--accent);
background: var(--buttonHoverBg);
}
> .icon {
margin-right: 0.75em;
flex-shrink: 0;
text-align: center;
opacity: 0.8;
&:empty {
display: none;
& + .text {
padding-left: 4px;
}
}
}
> .text {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
padding-right: 12px;
}
> .right {
margin-left: auto;
opacity: 0.7;
white-space: nowrap;
> .text:not(:empty) {
margin-right: 0.75em;
}
}
}
> .body {
background: var(--panel);
border-radius: 0 0 6px 6px;
}
&.opened {
> .header {
border-radius: 6px 6px 0 0;
}
}
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<div v-sticky-container v-panel class="adfeebaf _formBlock">
<div v-sticky-container class="adfeebaf _formBlock">
<div class="label"><slot name="label"></slot></div>
<div class="main _formRoot">
<slot></slot>
@ -17,6 +17,7 @@ export default defineComponent({
<style lang="scss" scoped>
.adfeebaf {
padding: 24px 24px;
border: solid 1px var(--divider);
border-radius: var(--radius);
> .label {

View File

@ -167,7 +167,7 @@ export default defineComponent({
// このコンポーネントが作成された時、非表示状態である場合がある
// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
const clock = setInterval(() => {
const clock = window.setInterval(() => {
if (prefixEl.value) {
if (prefixEl.value.offsetWidth) {
inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
@ -181,7 +181,7 @@ export default defineComponent({
}, 100);
onUnmounted(() => {
clearInterval(clock);
window.clearInterval(clock);
});
});
});

View File

@ -1,44 +0,0 @@
<template>
<FormSlot>
<template #label><slot name="label"></slot></template>
<div class="abcaccfa">
<slot :items="items"></slot>
<div v-if="empty" key="_empty_" class="empty">
<slot name="empty"></slot>
</div>
<MkButton v-show="more" class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template>
</MkButton>
</div>
</FormSlot>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import MkButton from '@/components/ui/button.vue';
import FormSlot from './slot.vue';
import paging from '@/scripts/paging';
export default defineComponent({
components: {
MkButton,
FormSlot,
},
mixins: [
paging({}),
],
props: {
pagination: {
required: true
},
},
});
</script>
<style lang="scss" scoped>
.abcaccfa {
}
</style>

View File

@ -1,6 +1,6 @@
<template>
<div
v-panel
v-adaptive-border
class="novjtctn"
:class="{ disabled, checked }"
:aria-checked="checked"
@ -53,7 +53,10 @@ export default defineComponent({
display: inline-block;
text-align: left;
cursor: pointer;
padding: 11px 14px;
padding: 10px 12px;
background-color: var(--panel);
background-clip: padding-box !important;
border: solid 1px var(--panel);
border-radius: 6px;
transition: all 0.3s;
@ -69,9 +72,13 @@ export default defineComponent({
}
}
&:hover {
border-color: var(--inputBorderHover) !important;
}
&.checked {
background: var(--accentedBg) !important;
border-color: var(--accent);
background-color: var(--accentedBg) !important;
border-color: var(--accentedBg) !important;
color: var(--accent);
&, * {
@ -89,11 +96,6 @@ export default defineComponent({
}
}
&:hover {
border-color: var(--inputBorderHover);
color: var(--accent);
}
> input {
position: absolute;
width: 0;

View File

@ -1,5 +1,5 @@
<template>
<div v-size="{ max: [500] }" v-sticky-container class="vrtktovh _formBlock">
<div class="vrtktovh _formBlock">
<div class="label"><slot name="label"></slot></div>
<div class="main _formRoot">
<slot></slot>
@ -7,20 +7,13 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
});
<script lang="ts" setup>
</script>
<style lang="scss" scoped>
.vrtktovh {
margin: 0;
border-top: solid 0.5px var(--divider);
border-bottom: solid 0.5px var(--divider);
padding: 24px 0;
& + .vrtktovh {
border-top: none;
@ -36,7 +29,7 @@ export default defineComponent({
> .label {
font-weight: bold;
padding: 0 0 16px 0;
margin: 1.5em 0 16px 0;
&:empty {
display: none;
@ -44,6 +37,7 @@ export default defineComponent({
}
> .main {
margin: 1.5em 0;
}
}
</style>

View File

@ -117,7 +117,7 @@ export default defineComponent({
// このコンポーネントが作成された時、非表示状態である場合がある
// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する
const clock = setInterval(() => {
const clock = window.setInterval(() => {
if (prefixEl.value) {
if (prefixEl.value.offsetWidth) {
inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px';
@ -131,7 +131,7 @@ export default defineComponent({
}, 100);
onUnmounted(() => {
clearInterval(clock);
window.clearInterval(clock);
});
});
});

View File

@ -0,0 +1,27 @@
<template>
<div class="terlnhxf _formBlock">
<slot></slot>
</div>
</template>
<script lang="ts" setup>
const props = withDefaults(defineProps<{
minWidth: number;
}>(), {
minWidth: 210,
});
const minWidth = props.minWidth + 'px';
</script>
<style lang="scss" scoped>
.terlnhxf {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(v-bind('minWidth'), 1fr));
grid-gap: 12px;
> ::v-deep(*) {
margin: 0 !important;
}
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<transition name="fade" mode="out-in">
<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
<div v-if="pending">
<MkLoading/>
</div>

View File

@ -13,7 +13,8 @@
<i class="check fas fa-check"></i>
</span>
<span class="label">
<span @click="toggle"><slot></slot></span>
<!-- TODO: 無名slotの方は廃止 -->
<span @click="toggle"><slot name="label"></slot><slot></slot></span>
<p class="caption"><slot name="caption"></slot></p>
</span>
</div>
@ -110,7 +111,7 @@ export default defineComponent({
}
> .label {
margin-left: 16px;
margin-left: 12px;
margin-top: 2px;
display: block;
transition: inherit;

View File

@ -4,130 +4,114 @@
</a>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { inject } from 'vue';
import * as os from '@/os';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { router } from '@/router';
import { url } from '@/config';
import { popout } from '@/scripts/popout';
import { ColdDeviceStorage } from '@/store';
import { popout as popout_ } from '@/scripts/popout';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
export default defineComponent({
inject: {
navHook: {
default: null
},
sideViewHook: {
default: null
const props = withDefaults(defineProps<{
to: string;
activeClass?: null | string;
behavior?: null | 'window' | 'browser' | 'modalWindow';
}>(), {
activeClass: null,
behavior: null,
});
const navHook = inject('navHook', null);
const sideViewHook = inject('sideViewHook', null);
const active = $computed(() => {
if (props.activeClass == null) return false;
const resolved = router.resolve(props.to);
if (resolved.path === router.currentRoute.value.path) return true;
if (resolved.name == null) return false;
if (router.currentRoute.value.name == null) return false;
return resolved.name === router.currentRoute.value.name;
});
function onContextmenu(ev) {
const selection = window.getSelection();
if (selection && selection.toString() !== '') return;
os.contextMenu([{
type: 'label',
text: props.to,
}, {
icon: 'fas fa-window-maximize',
text: i18n.locale.openInWindow,
action: () => {
os.pageWindow(props.to);
}
},
props: {
to: {
type: String,
required: true,
},
activeClass: {
type: String,
required: false,
},
behavior: {
type: String,
required: false,
},
},
computed: {
active() {
if (this.activeClass == null) return false;
const resolved = router.resolve(this.to);
if (resolved.path == this.$route.path) return true;
if (resolved.name == null) return false;
if (this.$route.name == null) return false;
return resolved.name == this.$route.name;
}, sideViewHook ? {
icon: 'fas fa-columns',
text: i18n.locale.openInSideView,
action: () => {
sideViewHook(props.to);
}
},
} : undefined, {
icon: 'fas fa-expand-alt',
text: i18n.locale.showInPage,
action: () => {
router.push(props.to);
}
}, null, {
icon: 'fas fa-external-link-alt',
text: i18n.locale.openInNewTab,
action: () => {
window.open(props.to, '_blank');
}
}, {
icon: 'fas fa-link',
text: i18n.locale.copyLink,
action: () => {
copyToClipboard(`${url}${props.to}`);
}
}], ev);
}
methods: {
onContextmenu(e) {
if (window.getSelection().toString() !== '') return;
os.contextMenu([{
type: 'label',
text: this.to,
}, {
icon: 'fas fa-window-maximize',
text: this.$ts.openInWindow,
action: () => {
os.pageWindow(this.to);
}
}, this.sideViewHook ? {
icon: 'fas fa-columns',
text: this.$ts.openInSideView,
action: () => {
this.sideViewHook(this.to);
}
} : undefined, {
icon: 'fas fa-expand-alt',
text: this.$ts.showInPage,
action: () => {
this.$router.push(this.to);
}
}, null, {
icon: 'fas fa-external-link-alt',
text: this.$ts.openInNewTab,
action: () => {
window.open(this.to, '_blank');
}
}, {
icon: 'fas fa-link',
text: this.$ts.copyLink,
action: () => {
copyToClipboard(`${url}${this.to}`);
}
}], e);
},
function openWindow() {
os.pageWindow(props.to);
}
window() {
os.pageWindow(this.to);
},
function modalWindow() {
os.modalPageWindow(props.to);
}
modalWindow() {
os.modalPageWindow(this.to);
},
function popout() {
popout_(props.to);
}
popout() {
popout(this.to);
},
function nav() {
if (props.behavior === 'browser') {
location.href = props.to;
return;
}
nav() {
if (this.behavior === 'browser') {
location.href = this.to;
return;
}
if (this.behavior) {
if (this.behavior === 'window') {
return this.window();
} else if (this.behavior === 'modalWindow') {
return this.modalWindow();
}
}
if (this.navHook) {
this.navHook(this.to);
} else {
if (this.$store.state.defaultSideView && this.sideViewHook && this.to !== '/') {
return this.sideViewHook(this.to);
}
if (this.$router.currentRoute.value.path === this.to) {
window.scroll({ top: 0, behavior: 'smooth' });
} else {
this.$router.push(this.to);
}
}
if (props.behavior) {
if (props.behavior === 'window') {
return openWindow();
} else if (props.behavior === 'modalWindow') {
return modalWindow();
}
}
});
if (navHook) {
navHook(props.to);
} else {
if (defaultStore.state.defaultSideView && sideViewHook && props.to !== '/') {
return sideViewHook(props.to);
}
if (router.currentRoute.value.path === props.to) {
window.scroll({ top: 0, behavior: 'smooth' });
} else {
router.push(props.to);
}
}
}
</script>

View File

@ -5,28 +5,17 @@
</span>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import * as misskey from 'misskey-js';
import { toUnicode } from 'punycode/';
import { host } from '@/config';
import { host as hostRaw } from '@/config';
export default defineComponent({
props: {
user: {
type: Object,
required: true
},
detail: {
type: Boolean,
default: false
},
},
data() {
return {
host: toUnicode(host),
};
}
});
defineProps<{
user: misskey.entities.UserDetailed;
detail?: boolean;
}>();
const host = toUnicode(hostRaw);
</script>
<style lang="scss" scoped>

View File

@ -20,7 +20,7 @@
<script lang="ts">
import { defineComponent, ref } from 'vue';
import { Instance, instance } from '@/instance';
import { instance } from '@/instance';
import { host } from '@/config';
import MkButton from '@/components/ui/button.vue';
import { defaultStore } from '@/store';
@ -48,9 +48,9 @@ export default defineComponent({
showMenu.value = !showMenu.value;
};
const choseAd = (): Instance['ads'][number] | null => {
const choseAd = (): (typeof instance)['ads'][number] | null => {
if (props.specify) {
return props.specify as Instance['ads'][number];
return props.specify as (typeof instance)['ads'][number];
}
const allAds = instance.ads.map(ad => defaultStore.state.mutedAds.includes(ad.id) ? {

View File

@ -1,74 +1,54 @@
<template>
<span v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :title="acct(user)" @click="onClick">
<span v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :title="acct(user)" @click="onClick">
<img class="inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
</span>
<MkA v-else v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat, square: $store.state.squareAvatars }" :to="userPage(user)" :title="acct(user)" :target="target">
<MkA v-else v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :to="userPage(user)" :title="acct(user)" :target="target">
<img class="inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
</MkA>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { onMounted, watch } from 'vue';
import * as misskey from 'misskey-js';
import { getStaticImageUrl } from '@/scripts/get-static-image-url';
import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
import { acct, userPage } from '@/filters/user';
import MkUserOnlineIndicator from '@/components/user-online-indicator.vue';
import { defaultStore } from '@/store';
export default defineComponent({
components: {
MkUserOnlineIndicator
},
props: {
user: {
type: Object,
required: true
},
target: {
required: false,
default: null
},
disableLink: {
required: false,
default: false
},
disablePreview: {
required: false,
default: false
},
showIndicator: {
required: false,
default: false
}
},
emits: ['click'],
computed: {
cat(): boolean {
return this.user.isCat;
},
url(): string {
return this.$store.state.disableShowingAnimatedImages
? getStaticImageUrl(this.user.avatarUrl)
: this.user.avatarUrl;
},
},
watch: {
'user.avatarBlurhash'() {
if (this.$el == null) return;
this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash);
}
},
mounted() {
this.$el.style.color = extractAvgColorFromBlurhash(this.user.avatarBlurhash);
},
methods: {
onClick(e) {
this.$emit('click', e);
},
acct,
userPage
}
const props = withDefaults(defineProps<{
user: misskey.entities.User;
target?: string | null;
disableLink?: boolean;
disablePreview?: boolean;
showIndicator?: boolean;
}>(), {
target: null,
disableLink: false,
disablePreview: false,
showIndicator: false,
});
const emit = defineEmits<{
(e: 'click', ev: MouseEvent): void;
}>();
const url = $computed(() => defaultStore.state.disableShowingAnimatedImages
? getStaticImageUrl(props.user.avatarUrl)
: props.user.avatarUrl);
function onClick(ev: MouseEvent) {
emit('click', ev);
}
let color = $ref();
watch(() => props.user.avatarBlurhash, () => {
color = extractAvgColorFromBlurhash(props.user.avatarBlurhash);
}, {
immediate: true,
});
</script>

View File

@ -8,19 +8,8 @@
</transition>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import MkButton from '@/components/ui/button.vue';
export default defineComponent({
components: {
MkButton,
},
data() {
return {
};
},
});
</script>
<style lang="scss" scoped>

View File

@ -4,27 +4,17 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
export default defineComponent({
props: {
inline: {
type: Boolean,
required: false,
default: false
},
colored: {
type: Boolean,
required: false,
default: true
},
mini: {
type: Boolean,
required: false,
default: false
},
}
const props = withDefaults(defineProps<{
inline?: boolean;
colored?: boolean;
mini?: boolean;
}>(), {
inline: false,
colored: true,
mini: false,
});
</script>

View File

@ -1,15 +1,23 @@
<template>
<mfm-core v-bind="$attrs" class="havbbuyv" :class="{ nowrap: $attrs['nowrap'] }"/>
<MfmCore :text="text" :plain="plain" :nowrap="nowrap" :author="author" :customEmojis="customEmojis" :isNote="isNote" class="havbbuyv" :class="{ nowrap }"/>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
import MfmCore from '@/components/mfm';
export default defineComponent({
components: {
MfmCore
}
const props = withDefaults(defineProps<{
text: string;
plain?: boolean;
nowrap?: boolean;
author?: any;
customEmojis?: any;
isNote?: boolean;
}>(), {
plain: false,
nowrap: false,
author: null,
isNote: true,
});
</script>

View File

@ -40,7 +40,7 @@ export default defineComponent({
return;
}
if (rect.width > props.contentMax || rect.width > 500) {
if (rect.width > props.contentMax || (rect.width > 360 && window.innerWidth > 400)) {
margin.value = props.marginMax;
} else {
margin.value = props.marginMin;

View File

@ -45,7 +45,7 @@ export default defineComponent({
calc();
const observer = new MutationObserver(() => {
setTimeout(() => {
window.setTimeout(() => {
calc();
}, 100);
});

View File

@ -1,73 +1,57 @@
<template>
<time :title="absolute">
<template v-if="mode == 'relative'">{{ relative }}</template>
<template v-else-if="mode == 'absolute'">{{ absolute }}</template>
<template v-else-if="mode == 'detail'">{{ absolute }} ({{ relative }})</template>
<template v-if="mode === 'relative'">{{ relative }}</template>
<template v-else-if="mode === 'absolute'">{{ absolute }}</template>
<template v-else-if="mode === 'detail'">{{ absolute }} ({{ relative }})</template>
</time>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { onUnmounted } from 'vue';
import { i18n } from '@/i18n';
export default defineComponent({
props: {
time: {
type: [Date, String],
required: true
},
mode: {
type: String,
default: 'relative'
}
},
data() {
return {
tickId: null,
now: new Date()
};
},
computed: {
_time(): Date {
return typeof this.time == 'string' ? new Date(this.time) : this.time;
},
absolute(): string {
return this._time.toLocaleString();
},
relative(): string {
const time = this._time;
const ago = (this.now.getTime() - time.getTime()) / 1000/*ms*/;
return (
ago >= 31536000 ? this.$t('_ago.yearsAgo', { n: (~~(ago / 31536000)).toString() }) :
ago >= 2592000 ? this.$t('_ago.monthsAgo', { n: (~~(ago / 2592000)).toString() }) :
ago >= 604800 ? this.$t('_ago.weeksAgo', { n: (~~(ago / 604800)).toString() }) :
ago >= 86400 ? this.$t('_ago.daysAgo', { n: (~~(ago / 86400)).toString() }) :
ago >= 3600 ? this.$t('_ago.hoursAgo', { n: (~~(ago / 3600)).toString() }) :
ago >= 60 ? this.$t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) :
ago >= 10 ? this.$t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) :
ago >= -1 ? this.$ts._ago.justNow :
ago < -1 ? this.$ts._ago.future :
this.$ts._ago.unknown);
}
},
created() {
if (this.mode == 'relative' || this.mode == 'detail') {
this.tickId = window.requestAnimationFrame(this.tick);
}
},
unmounted() {
if (this.mode === 'relative' || this.mode === 'detail') {
window.clearTimeout(this.tickId);
}
},
methods: {
tick() {
// TODO: パフォーマンス向上のため、このコンポーネントが画面内に表示されている場合のみ更新する
this.now = new Date();
this.tickId = setTimeout(() => {
window.requestAnimationFrame(this.tick);
}, 10000);
}
}
const props = withDefaults(defineProps<{
time: Date | string;
mode?: 'relative' | 'absolute' | 'detail';
}>(), {
mode: 'relative',
});
const _time = typeof props.time == 'string' ? new Date(props.time) : props.time;
const absolute = _time.toLocaleString();
let now = $ref(new Date());
const relative = $computed(() => {
const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/;
return (
ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: (~~(ago / 31536000)).toString() }) :
ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: (~~(ago / 2592000)).toString() }) :
ago >= 604800 ? i18n.t('_ago.weeksAgo', { n: (~~(ago / 604800)).toString() }) :
ago >= 86400 ? i18n.t('_ago.daysAgo', { n: (~~(ago / 86400)).toString() }) :
ago >= 3600 ? i18n.t('_ago.hoursAgo', { n: (~~(ago / 3600)).toString() }) :
ago >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) :
ago >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) :
ago >= -1 ? i18n.locale._ago.justNow :
ago < -1 ? i18n.locale._ago.future :
i18n.locale._ago.unknown);
});
function tick() {
// TODO: パフォーマンス向上のため、このコンポーネントが画面内に表示されている場合のみ更新する
now = new Date();
tickId = window.setTimeout(() => {
window.requestAnimationFrame(tick);
}, 10000);
}
let tickId: number;
if (props.mode === 'relative' || props.mode === 'detail') {
tickId = window.requestAnimationFrame(tick);
onUnmounted(() => {
window.clearTimeout(tickId);
});
}
</script>

View File

@ -2,19 +2,14 @@
<Mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
import * as misskey from 'misskey-js';
export default defineComponent({
props: {
user: {
type: Object,
required: true
},
nowrap: {
type: Boolean,
default: true
},
}
const props = withDefaults(defineProps<{
user: misskey.entities.User;
nowrap?: boolean;
}>(), {
nowrap: true,
});
</script>

View File

@ -5,31 +5,18 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import * as os from '@/os';
<script lang="ts" setup>
import { ref } from 'vue';
export default defineComponent({
props: {
q: {
type: String,
required: true,
}
},
data() {
return {
query: null,
};
},
mounted() {
this.query = this.q;
},
methods: {
search() {
window.open(`https://www.google.com/search?q=${this.query}`, '_blank');
}
}
});
const props = defineProps<{
q: string;
}>();
const query = ref(props.q);
const search = () => {
window.open(`https://www.google.com/search?q=${query.value}`, '_blank');
};
</script>
<style lang="scss" scoped>

View File

@ -1,8 +1,8 @@
<template>
<MkModal ref="modal" :z-priority="'middle'" @click="$refs.modal.close()" @closed="$emit('closed')">
<MkModal ref="modal" :z-priority="'middle'" @click="modal.close()" @closed="emit('closed')">
<div class="xubzgfga">
<header>{{ image.name }}</header>
<img :src="image.url" :alt="image.comment" :title="image.comment" @click="$refs.modal.close()"/>
<img :src="image.url" :alt="image.comment" :title="image.comment" @click="modal.close()"/>
<footer>
<span>{{ image.type }}</span>
<span>{{ bytes(image.size) }}</span>
@ -12,31 +12,23 @@
</MkModal>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
import * as misskey from 'misskey-js';
import bytes from '@/filters/bytes';
import number from '@/filters/number';
import MkModal from '@/components/ui/modal.vue';
export default defineComponent({
components: {
MkModal,
},
props: {
image: {
type: Object,
required: true
},
},
emits: ['closed'],
methods: {
bytes,
number,
}
const props = withDefaults(defineProps<{
image: misskey.entities.DriveFile;
}>(), {
});
const emit = defineEmits<{
(e: 'closed'): void;
}>();
const modal = $ref<InstanceType<typeof MkModal>>();
</script>
<style lang="scss" scoped>

View File

@ -5,67 +5,43 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { onMounted } from 'vue';
import { decode } from 'blurhash';
export default defineComponent({
props: {
src: {
type: String,
required: false,
default: null
},
hash: {
type: String,
required: true
},
alt: {
type: String,
required: false,
default: '',
},
title: {
type: String,
required: false,
default: null,
},
size: {
type: Number,
required: false,
default: 64
},
cover: {
type: Boolean,
required: false,
default: true,
}
},
const props = withDefaults(defineProps<{
src?: string | null;
hash: string;
alt?: string;
title?: string | null;
size?: number;
cover?: boolean;
}>(), {
src: null,
alt: '',
title: null,
size: 64,
cover: true,
});
data() {
return {
loaded: false,
};
},
const canvas = $ref<HTMLCanvasElement>();
let loaded = $ref(false);
mounted() {
this.draw();
},
function draw() {
if (props.hash == null) return;
const pixels = decode(props.hash, props.size, props.size);
const ctx = canvas.getContext('2d');
const imageData = ctx!.createImageData(props.size, props.size);
imageData.data.set(pixels);
ctx!.putImageData(imageData, 0, 0);
}
methods: {
draw() {
if (this.hash == null) return;
const pixels = decode(this.hash, this.size, this.size);
const ctx = (this.$refs.canvas as HTMLCanvasElement).getContext('2d');
const imageData = ctx!.createImageData(this.size, this.size);
imageData.data.set(pixels);
ctx!.putImageData(imageData, 0, 0);
},
function onLoad() {
loaded = true;
}
onLoad() {
this.loaded = true;
}
}
onMounted(() => {
draw();
});
</script>

View File

@ -1,5 +1,5 @@
<template>
<div class="zbcjwnqg" style="margin-top: -8px;">
<div class="zbcjwnqg">
<div class="selects" style="display: flex;">
<MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
<optgroup :label="$ts.federation">
@ -29,16 +29,16 @@
<option value="day">{{ $ts.perDay }}</option>
</MkSelect>
</div>
<MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart>
<div class="chart">
<MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref, watch } from 'vue';
import { defineComponent, ref } from 'vue';
import MkSelect from '@/components/form/select.vue';
import MkChart from '@/components/chart.vue';
import * as os from '@/os';
import { defaultStore } from '@/store';
export default defineComponent({
components: {
@ -74,7 +74,10 @@ export default defineComponent({
<style lang="scss" scoped>
.zbcjwnqg {
> .selects {
padding: 8px 16px 0 16px;
}
> .chart {
padding: 8px 0 0 0;
}
}
</style>

View File

@ -1,41 +1,22 @@
<template>
<div class="hpaizdrt" :style="bg">
<img v-if="info.faviconUrl" class="icon" :src="info.faviconUrl"/>
<span class="name">{{ info.name }}</span>
<img v-if="instance.faviconUrl" class="icon" :src="instance.faviconUrl"/>
<span class="name">{{ instance.name }}</span>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { instanceName } from '@/config';
<script lang="ts" setup>
import { } from 'vue';
export default defineComponent({
props: {
instance: {
type: Object,
required: false
},
},
const props = defineProps<{
instance: any; // TODO
}>();
data() {
return {
info: this.instance || {
faviconUrl: '/favicon.ico',
name: instanceName,
themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content
}
}
},
const themeColor = props.instance.themeColor || '#777777';
computed: {
bg(): any {
const themeColor = this.info.themeColor || '#777777';
return {
background: `linear-gradient(90deg, ${themeColor}, ${themeColor + '00'})`
};
}
}
});
const bg = {
background: `linear-gradient(90deg, ${themeColor}, ${themeColor + '00'})`
};
</script>
<style lang="scss" scoped>

View File

@ -1,5 +1,5 @@
<template>
<div class="alqyeyti">
<div class="alqyeyti" :class="{ oneline }">
<div class="key">
<slot name="key"></slot>
</div>
@ -22,6 +22,11 @@ export default defineComponent({
required: false,
default: null,
},
oneline: {
type: Boolean,
required: false,
default: false,
},
},
setup(props) {
@ -39,10 +44,30 @@ export default defineComponent({
<style lang="scss" scoped>
.alqyeyti {
> .key, > .value {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
> .key {
font-size: 0.85em;
padding: 0 0 0.25em 0;
opacity: 0.75;
}
&.oneline {
display: flex;
> .key {
width: 30%;
font-size: 1em;
padding: 0 8px 0 0;
}
> .value {
width: 70%;
}
}
}
</style>

View File

@ -1,82 +1,36 @@
<template>
<component :is="self ? 'MkA' : 'a'" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
<component :is="self ? 'MkA' : 'a'" ref="el" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
:title="url"
@mouseover="onMouseover"
@mouseleave="onMouseleave"
>
<slot></slot>
<i v-if="target === '_blank'" class="fas fa-external-link-square-alt icon"></i>
</component>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
import { url as local } from '@/config';
import { isTouchUsing } from '@/scripts/touch';
import { useTooltip } from '@/scripts/use-tooltip';
import * as os from '@/os';
export default defineComponent({
props: {
url: {
type: String,
required: true,
},
rel: {
type: String,
required: false,
}
},
data() {
const self = this.url.startsWith(local);
return {
local,
self: self,
attr: self ? 'to' : 'href',
target: self ? null : '_blank',
showTimer: null,
hideTimer: null,
checkTimer: null,
close: null,
};
},
methods: {
async showPreview() {
if (!document.body.contains(this.$el)) return;
if (this.close) return;
const props = withDefaults(defineProps<{
url: string;
rel?: null | string;
}>(), {
});
const { dispose } = await os.popup(import('@/components/url-preview-popup.vue'), {
url: this.url,
source: this.$el
});
const self = props.url.startsWith(local);
const attr = self ? 'to' : 'href';
const target = self ? null : '_blank';
this.close = () => {
dispose();
};
const el = $ref();
this.checkTimer = setInterval(() => {
if (!document.body.contains(this.$el)) this.closePreview();
}, 1000);
},
closePreview() {
if (this.close) {
clearInterval(this.checkTimer);
this.close();
this.close = null;
}
},
onMouseover() {
if (isTouchUsing) return;
clearTimeout(this.showTimer);
clearTimeout(this.hideTimer);
this.showTimer = setTimeout(this.showPreview, 500);
},
onMouseleave() {
if (isTouchUsing) return;
clearTimeout(this.showTimer);
clearTimeout(this.hideTimer);
this.hideTimer = setTimeout(this.closePreview, 500);
}
}
useTooltip($$(el), (showing) => {
os.popup(import('@/components/url-preview-popup.vue'), {
showing,
url: props.url,
source: el,
}, {}, 'closed');
});
</script>

View File

@ -6,7 +6,7 @@
<span>{{ $ts.clickToShow }}</span>
</div>
<div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" class="audio">
<audio ref="audio"
<audio ref="audioEl"
class="audio"
:src="media.url"
:title="media.name"
@ -25,34 +25,26 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import * as os from '@/os';
<script lang="ts" setup>
import { onMounted } from 'vue';
import * as misskey from 'misskey-js';
import { ColdDeviceStorage } from '@/store';
export default defineComponent({
props: {
media: {
type: Object,
required: true
}
},
data() {
return {
hide: true,
};
},
mounted() {
const audioTag = this.$refs.audio as HTMLAudioElement;
if (audioTag) audioTag.volume = ColdDeviceStorage.get('mediaVolume');
},
methods: {
volumechange() {
const audioTag = this.$refs.audio as HTMLAudioElement;
ColdDeviceStorage.set('mediaVolume', audioTag.volume);
},
},
})
const props = withDefaults(defineProps<{
media: misskey.entities.DriveFile;
}>(), {
});
const audioEl = $ref<HTMLAudioElement | null>();
let hide = $ref(true);
function volumechange() {
if (audioEl) ColdDeviceStorage.set('mediaVolume', audioEl.volume);
}
onMounted(() => {
if (audioEl) audioEl.volume = ColdDeviceStorage.get('mediaVolume');
});
</script>
<style lang="scss" scoped>

View File

@ -3,7 +3,7 @@
<XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/>
<div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container">
<div ref="gallery" :data-count="mediaList.filter(media => previewable(media)).length">
<template v-for="media in mediaList">
<template v-for="media in mediaList.filter(media => previewable(media))">
<XVideo v-if="media.type.startsWith('video')" :key="media.id" :video="media"/>
<XImage v-else-if="media.type.startsWith('image')" :key="media.id" class="image" :data-id="media.id" :image="media" :raw="raw"/>
</template>
@ -22,6 +22,7 @@ import XBanner from './media-banner.vue';
import XImage from './media-image.vue';
import XVideo from './media-video.vue';
import * as os from '@/os';
import { FILE_TYPE_BROWSERSAFE } from '@/const';
import { defaultStore } from '@/store';
export default defineComponent({
@ -44,18 +45,23 @@ export default defineComponent({
onMounted(() => {
const lightbox = new PhotoSwipeLightbox({
dataSource: props.mediaList.filter(media => media.type.startsWith('image')).map(media => {
const item = {
src: media.url,
w: media.properties.width,
h: media.properties.height,
alt: media.name,
};
if (media.properties.orientation != null && media.properties.orientation >= 5) {
[item.w, item.h] = [item.h, item.w];
}
return item;
}),
dataSource: props.mediaList
.filter(media => {
if (media.type === 'image/svg+xml') return true; // svgのwebpublicはpngなのでtrue
return media.type.startsWith('image') && FILE_TYPE_BROWSERSAFE.includes(media.type);
})
.map(media => {
const item = {
src: media.url,
w: media.properties.width,
h: media.properties.height,
alt: media.name,
};
if (media.properties.orientation != null && media.properties.orientation >= 5) {
[item.w, item.h] = [item.h, item.w];
}
return item;
}),
gallery: gallery.value,
children: '.image',
thumbSelector: '.image',
@ -99,7 +105,9 @@ export default defineComponent({
});
const previewable = (file: misskey.entities.DriveFile): boolean => {
return file.type.startsWith('video') || file.type.startsWith('image');
if (file.type === 'image/svg+xml') return true; // svgのwebpublic/thumbnailはpngなのでtrue
// FILE_TYPE_BROWSERSAFEに適合しないものはブラウザで表示するのに不適切
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
};
return {

View File

@ -22,26 +22,16 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import * as os from '@/os';
<script lang="ts" setup>
import { ref } from 'vue';
import * as misskey from 'misskey-js';
import { defaultStore } from '@/store';
export default defineComponent({
props: {
video: {
type: Object,
required: true
}
},
data() {
return {
hide: true,
};
},
created() {
this.hide = (this.$store.state.nsfw === 'force') ? true : this.video.isSensitive && (this.$store.state.nsfw !== 'ignore');
},
});
const props = defineProps<{
video: misskey.entities.DriveFile;
}>();
const hide = ref((defaultStore.state.nsfw === 'force') ? true : props.video.isSensitive && (defaultStore.state.nsfw !== 'ignore'));
</script>
<style lang="scss" scoped>

View File

@ -63,10 +63,10 @@ export default defineComponent({
this.draw();
// Vueが何故かWatchを発動させない場合があるので
this.clock = setInterval(this.draw, 1000);
this.clock = window.setInterval(this.draw, 1000);
},
beforeUnmount() {
clearInterval(this.clock);
window.clearInterval(this.clock);
},
methods: {
draw() {

View File

@ -153,8 +153,8 @@ export default defineComponent({
this.$refs.window.close();
},
onContextmenu(e) {
os.contextMenu(this.contextmenu, e);
onContextmenu(ev: MouseEvent) {
os.contextMenu(this.contextmenu, ev);
}
},
});

View File

@ -4,12 +4,13 @@
v-show="!isDeleted"
v-hotkey="keymap"
v-size="{ max: [500, 450, 350, 300] }"
ref="el"
class="lxwezrsl _block"
:tabindex="!isDeleted ? '-1' : null"
:class="{ renote: isRenote }"
>
<XSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/>
<XSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
<MkNoteSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/>
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
<div v-if="isRenote" class="renote">
<MkAvatar class="avatar" :user="note.user"/>
<i class="fas fa-retweet"></i>
@ -107,7 +108,7 @@
</footer>
</div>
</article>
<XSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/>
<MkNoteSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/>
</div>
<div v-else class="_panel muted" @click="muted = false">
<I18n :src="$ts.userSaysSomething" tag="small">
@ -120,764 +121,171 @@
</div>
</template>
<script lang="ts">
import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
<script lang="ts" setup>
import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue';
import * as mfm from 'mfm-js';
import { sum } from '@/scripts/array';
import XSub from './note.sub.vue';
import XNoteHeader from './note-header.vue';
import * as misskey from 'misskey-js';
import MkNoteSub from './MkNoteSub.vue';
import XNoteSimple from './note-simple.vue';
import XReactionsViewer from './reactions-viewer.vue';
import XMediaList from './media-list.vue';
import XCwButton from './cw-button.vue';
import XPoll from './poll.vue';
import XRenoteButton from './renote-button.vue';
import MkUrlPreview from '@/components/url-preview.vue';
import MkInstanceTicker from '@/components/instance-ticker.vue';
import { pleaseLogin } from '@/scripts/please-login';
import { focusPrev, focusNext } from '@/scripts/focus';
import { url } from '@/config';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { checkWordMute } from '@/scripts/check-word-mute';
import { userPage } from '@/filters/user';
import { notePage } from '@/filters/note';
import * as os from '@/os';
import { noteActions, noteViewInterruptors } from '@/store';
import { defaultStore, noteViewInterruptors } from '@/store';
import { reactionPicker } from '@/scripts/reaction-picker';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
// TODO: note.vueとほぼ同じなので共通化したい
export default defineComponent({
components: {
XSub,
XNoteHeader,
XNoteSimple,
XReactionsViewer,
XMediaList,
XCwButton,
XPoll,
XRenoteButton,
MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
},
inject: {
inChannel: {
default: null
},
},
props: {
note: {
type: Object,
required: true
},
},
emits: ['update:note'],
data() {
return {
connection: null,
conversation: [],
replies: [],
showContent: false,
isDeleted: false,
muted: false,
translation: null,
translating: false,
notePage,
};
},
computed: {
rs() {
return this.$store.state.reactions;
},
keymap(): any {
return {
'r': () => this.reply(true),
'e|a|plus': () => this.react(true),
'q': () => this.$refs.renoteButton.renote(true),
'f|b': this.favorite,
'delete|ctrl+d': this.del,
'ctrl+q': this.renoteDirectly,
'up|k|shift+tab': this.focusBefore,
'down|j|tab': this.focusAfter,
'esc': this.blur,
'm|o': () => this.menu(true),
's': this.toggleShowContent,
'1': () => this.reactDirectly(this.rs[0]),
'2': () => this.reactDirectly(this.rs[1]),
'3': () => this.reactDirectly(this.rs[2]),
'4': () => this.reactDirectly(this.rs[3]),
'5': () => this.reactDirectly(this.rs[4]),
'6': () => this.reactDirectly(this.rs[5]),
'7': () => this.reactDirectly(this.rs[6]),
'8': () => this.reactDirectly(this.rs[7]),
'9': () => this.reactDirectly(this.rs[8]),
'0': () => this.reactDirectly(this.rs[9]),
};
},
isRenote(): boolean {
return (this.note.renote &&
this.note.text == null &&
this.note.fileIds.length == 0 &&
this.note.poll == null);
},
appearNote(): any {
return this.isRenote ? this.note.renote : this.note;
},
isMyNote(): boolean {
return this.$i && (this.$i.id === this.appearNote.userId);
},
isMyRenote(): boolean {
return this.$i && (this.$i.id === this.note.userId);
},
reactionsCount(): number {
return this.appearNote.reactions
? sum(Object.values(this.appearNote.reactions))
: 0;
},
urls(): string[] {
if (this.appearNote.text) {
return extractUrlFromMfm(mfm.parse(this.appearNote.text));
} else {
return null;
}
},
showTicker() {
if (this.$store.state.instanceTicker === 'always') return true;
if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true;
return false;
}
},
async created() {
if (this.$i) {
this.connection = os.stream;
}
this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords);
// plugin
if (noteViewInterruptors.length > 0) {
let result = this.note;
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(JSON.parse(JSON.stringify(result)));
}
this.$emit('update:note', Object.freeze(result));
}
os.api('notes/children', {
noteId: this.appearNote.id,
limit: 30
}).then(replies => {
this.replies = replies;
});
if (this.appearNote.replyId) {
os.api('notes/conversation', {
noteId: this.appearNote.replyId
}).then(conversation => {
this.conversation = conversation.reverse();
});
}
},
mounted() {
this.capture(true);
if (this.$i) {
this.connection.on('_connected_', this.onStreamConnected);
}
},
beforeUnmount() {
this.decapture(true);
if (this.$i) {
this.connection.off('_connected_', this.onStreamConnected);
}
},
methods: {
updateAppearNote(v) {
this.$emit('update:note', Object.freeze(this.isRenote ? {
...this.note,
renote: {
...this.note.renote,
...v
}
} : {
...this.note,
...v
}));
},
readPromo() {
os.api('promo/read', {
noteId: this.appearNote.id
});
this.isDeleted = true;
},
capture(withHandler = false) {
if (this.$i) {
// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id });
if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
}
},
decapture(withHandler = false) {
if (this.$i) {
this.connection.send('un', {
id: this.appearNote.id
});
if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated);
}
},
onStreamConnected() {
this.capture();
},
onStreamNoteUpdated(data) {
const { type, id, body } = data;
if (id !== this.appearNote.id) return;
switch (type) {
case 'reacted': {
const reaction = body.reaction;
// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
let n = {
...this.appearNote,
};
if (body.emoji) {
const emojis = this.appearNote.emojis || [];
if (!emojis.includes(body.emoji)) {
n.emojis = [...emojis, body.emoji];
}
}
// TODO: reactionsプロパティがない場合ってあったっけ なければ || {} は消せる
const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
// Increment the count
n.reactions = {
...this.appearNote.reactions,
[reaction]: currentCount + 1
};
if (body.userId === this.$i.id) {
n.myReaction = reaction;
}
this.updateAppearNote(n);
break;
}
case 'unreacted': {
const reaction = body.reaction;
// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
let n = {
...this.appearNote,
};
// TODO: reactionsプロパティがない場合ってあったっけ なければ || {} は消せる
const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
// Decrement the count
n.reactions = {
...this.appearNote.reactions,
[reaction]: Math.max(0, currentCount - 1)
};
if (body.userId === this.$i.id) {
n.myReaction = null;
}
this.updateAppearNote(n);
break;
}
case 'pollVoted': {
const choice = body.choice;
// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
let n = {
...this.appearNote,
};
const choices = [...this.appearNote.poll.choices];
choices[choice] = {
...choices[choice],
votes: choices[choice].votes + 1,
...(body.userId === this.$i.id ? {
isVoted: true
} : {})
};
n.poll = {
...this.appearNote.poll,
choices: choices
};
this.updateAppearNote(n);
break;
}
case 'deleted': {
this.isDeleted = true;
break;
}
}
},
reply(viaKeyboard = false) {
pleaseLogin();
os.post({
reply: this.appearNote,
animation: !viaKeyboard,
}, () => {
this.focus();
});
},
renoteDirectly() {
os.apiWithDialog('notes/create', {
renoteId: this.appearNote.id
}, undefined, (res: any) => {
os.alert({
type: 'success',
text: this.$ts.renoted,
});
}, (e: Error) => {
if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') {
os.alert({
type: 'error',
text: this.$ts.cantRenote,
});
} else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') {
os.alert({
type: 'error',
text: this.$ts.cantReRenote,
});
}
});
},
react(viaKeyboard = false) {
pleaseLogin();
this.blur();
reactionPicker.show(this.$refs.reactButton, reaction => {
os.api('notes/reactions/create', {
noteId: this.appearNote.id,
reaction: reaction
});
}, () => {
this.focus();
});
},
reactDirectly(reaction) {
os.api('notes/reactions/create', {
noteId: this.appearNote.id,
reaction: reaction
});
},
undoReact(note) {
const oldReaction = note.myReaction;
if (!oldReaction) return;
os.api('notes/reactions/delete', {
noteId: note.id
});
},
favorite() {
pleaseLogin();
os.apiWithDialog('notes/favorites/create', {
noteId: this.appearNote.id
}, undefined, (res: any) => {
os.alert({
type: 'success',
text: this.$ts.favorited,
});
}, (e: Error) => {
if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') {
os.alert({
type: 'error',
text: this.$ts.alreadyFavorited,
});
} else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') {
os.alert({
type: 'error',
text: this.$ts.cantFavorite,
});
}
});
},
del() {
os.confirm({
type: 'warning',
text: this.$ts.noteDeleteConfirm,
}).then(({ canceled }) => {
if (canceled) return;
os.api('notes/delete', {
noteId: this.appearNote.id
});
});
},
delEdit() {
os.confirm({
type: 'warning',
text: this.$ts.deleteAndEditConfirm,
}).then(({ canceled }) => {
if (canceled) return;
os.api('notes/delete', {
noteId: this.appearNote.id
});
os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel });
});
},
toggleFavorite(favorite: boolean) {
os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
noteId: this.appearNote.id
});
},
toggleWatch(watch: boolean) {
os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', {
noteId: this.appearNote.id
});
},
toggleThreadMute(mute: boolean) {
os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
noteId: this.appearNote.id
});
},
getMenu() {
let menu;
if (this.$i) {
const statePromise = os.api('notes/state', {
noteId: this.appearNote.id
});
menu = [{
icon: 'fas fa-copy',
text: this.$ts.copyContent,
action: this.copyContent
}, {
icon: 'fas fa-link',
text: this.$ts.copyLink,
action: this.copyLink
}, (this.appearNote.url || this.appearNote.uri) ? {
icon: 'fas fa-external-link-square-alt',
text: this.$ts.showOnRemote,
action: () => {
window.open(this.appearNote.url || this.appearNote.uri, '_blank');
}
} : undefined,
{
icon: 'fas fa-share-alt',
text: this.$ts.share,
action: this.share
},
this.$instance.translatorAvailable ? {
icon: 'fas fa-language',
text: this.$ts.translate,
action: this.translate
} : undefined,
null,
statePromise.then(state => state.isFavorited ? {
icon: 'fas fa-star',
text: this.$ts.unfavorite,
action: () => this.toggleFavorite(false)
} : {
icon: 'fas fa-star',
text: this.$ts.favorite,
action: () => this.toggleFavorite(true)
}),
{
icon: 'fas fa-paperclip',
text: this.$ts.clip,
action: () => this.clip()
},
(this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? {
icon: 'fas fa-eye-slash',
text: this.$ts.unwatch,
action: () => this.toggleWatch(false)
} : {
icon: 'fas fa-eye',
text: this.$ts.watch,
action: () => this.toggleWatch(true)
}) : undefined,
statePromise.then(state => state.isMutedThread ? {
icon: 'fas fa-comment-slash',
text: this.$ts.unmuteThread,
action: () => this.toggleThreadMute(false)
} : {
icon: 'fas fa-comment-slash',
text: this.$ts.muteThread,
action: () => this.toggleThreadMute(true)
}),
this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? {
icon: 'fas fa-thumbtack',
text: this.$ts.unpin,
action: () => this.togglePin(false)
} : {
icon: 'fas fa-thumbtack',
text: this.$ts.pin,
action: () => this.togglePin(true)
} : undefined,
/*...(this.$i.isModerator || this.$i.isAdmin ? [
null,
{
icon: 'fas fa-bullhorn',
text: this.$ts.promote,
action: this.promote
}]
: []
),*/
...(this.appearNote.userId != this.$i.id ? [
null,
{
icon: 'fas fa-exclamation-circle',
text: this.$ts.reportAbuse,
action: () => {
const u = `${url}/notes/${this.appearNote.id}`;
os.popup(import('@/components/abuse-report-window.vue'), {
user: this.appearNote.user,
initialComment: `Note: ${u}\n-----\n`
}, {}, 'closed');
}
}]
: []
),
...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [
null,
this.appearNote.userId == this.$i.id ? {
icon: 'fas fa-edit',
text: this.$ts.deleteAndEdit,
action: this.delEdit
} : undefined,
{
icon: 'fas fa-trash-alt',
text: this.$ts.delete,
danger: true,
action: this.del
}]
: []
)]
.filter(x => x !== undefined);
} else {
menu = [{
icon: 'fas fa-copy',
text: this.$ts.copyContent,
action: this.copyContent
}, {
icon: 'fas fa-link',
text: this.$ts.copyLink,
action: this.copyLink
}, (this.appearNote.url || this.appearNote.uri) ? {
icon: 'fas fa-external-link-square-alt',
text: this.$ts.showOnRemote,
action: () => {
window.open(this.appearNote.url || this.appearNote.uri, '_blank');
}
} : undefined]
.filter(x => x !== undefined);
}
if (noteActions.length > 0) {
menu = menu.concat([null, ...noteActions.map(action => ({
icon: 'fas fa-plug',
text: action.title,
action: () => {
action.handler(this.appearNote);
}
}))]);
}
return menu;
},
onContextmenu(e) {
const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true;
if (el.parentElement) {
return isLink(el.parentElement);
}
};
if (isLink(e.target)) return;
if (window.getSelection().toString() !== '') return;
if (this.$store.state.useReactionPickerForContextMenu) {
e.preventDefault();
this.react();
} else {
os.contextMenu(this.getMenu(), e).then(this.focus);
}
},
menu(viaKeyboard = false) {
os.popupMenu(this.getMenu(), this.$refs.menuButton, {
viaKeyboard
}).then(this.focus);
},
showRenoteMenu(viaKeyboard = false) {
if (!this.isMyRenote) return;
os.popupMenu([{
text: this.$ts.unrenote,
icon: 'fas fa-trash-alt',
danger: true,
action: () => {
os.api('notes/delete', {
noteId: this.note.id
});
this.isDeleted = true;
}
}], this.$refs.renoteTime, {
viaKeyboard: viaKeyboard
});
},
toggleShowContent() {
this.showContent = !this.showContent;
},
copyContent() {
copyToClipboard(this.appearNote.text);
os.success();
},
copyLink() {
copyToClipboard(`${url}/notes/${this.appearNote.id}`);
os.success();
},
togglePin(pin: boolean) {
os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
noteId: this.appearNote.id
}, undefined, null, e => {
if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
os.alert({
type: 'error',
text: this.$ts.pinLimitExceeded
});
}
});
},
async clip() {
const clips = await os.api('clips/list');
os.popupMenu([{
icon: 'fas fa-plus',
text: this.$ts.createNew,
action: async () => {
const { canceled, result } = await os.form(this.$ts.createNewClip, {
name: {
type: 'string',
label: this.$ts.name
},
description: {
type: 'string',
required: false,
multiline: true,
label: this.$ts.description
},
isPublic: {
type: 'boolean',
label: this.$ts.public,
default: false
}
});
if (canceled) return;
const clip = await os.apiWithDialog('clips/create', result);
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
}
}, null, ...clips.map(clip => ({
text: clip.name,
action: () => {
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
}
}))], this.$refs.menuButton, {
}).then(this.focus);
},
async promote() {
const { canceled, result: days } = await os.inputNumber({
title: this.$ts.numberOfDays,
});
if (canceled) return;
os.apiWithDialog('admin/promo/create', {
noteId: this.appearNote.id,
expiresAt: Date.now() + (86400000 * days)
});
},
share() {
navigator.share({
title: this.$t('noteOf', { user: this.appearNote.user.name }),
text: this.appearNote.text,
url: `${url}/notes/${this.appearNote.id}`
});
},
async translate() {
if (this.translation != null) return;
this.translating = true;
const res = await os.api('notes/translate', {
noteId: this.appearNote.id,
targetLang: localStorage.getItem('lang') || navigator.language,
});
this.translating = false;
this.translation = res;
},
focus() {
this.$el.focus();
},
blur() {
this.$el.blur();
},
focusBefore() {
focusPrev(this.$el);
},
focusAfter() {
focusNext(this.$el);
},
userPage
}
import { $i } from '@/account';
import { i18n } from '@/i18n';
import { getNoteMenu } from '@/scripts/get-note-menu';
import { useNoteCapture } from '@/scripts/use-note-capture';
const props = defineProps<{
note: misskey.entities.Note;
pinned?: boolean;
}>();
const inChannel = inject('inChannel', null);
const isRenote = (
props.note.renote != null &&
props.note.text == null &&
props.note.fileIds.length === 0 &&
props.note.poll == null
);
const el = ref<HTMLElement>();
const menuButton = ref<HTMLElement>();
const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
const renoteTime = ref<HTMLElement>();
const reactButton = ref<HTMLElement>();
let appearNote = $ref(isRenote ? props.note.renote as misskey.entities.Note : props.note);
const isMyRenote = $i && ($i.id === props.note.userId);
const showContent = ref(false);
const isDeleted = ref(false);
const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
const translation = ref(null);
const translating = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
const conversation = ref<misskey.entities.Note[]>([]);
const replies = ref<misskey.entities.Note[]>([]);
const keymap = {
'r': () => reply(true),
'e|a|plus': () => react(true),
'q': () => renoteButton.value.renote(true),
'esc': blur,
'm|o': () => menu(true),
's': () => showContent.value != showContent.value,
};
useNoteCapture({
appearNote: $$(appearNote),
rootEl: el,
});
function reply(viaKeyboard = false): void {
pleaseLogin();
os.post({
reply: appearNote,
animation: !viaKeyboard,
}, () => {
focus();
});
}
function react(viaKeyboard = false): void {
pleaseLogin();
blur();
reactionPicker.show(reactButton.value, reaction => {
os.api('notes/reactions/create', {
noteId: appearNote.id,
reaction: reaction
});
}, () => {
focus();
});
}
function undoReact(note): void {
const oldReaction = note.myReaction;
if (!oldReaction) return;
os.api('notes/reactions/delete', {
noteId: note.id
});
}
function onContextmenu(ev: MouseEvent): void {
const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true;
if (el.parentElement) {
return isLink(el.parentElement);
}
};
if (isLink(ev.target)) return;
if (window.getSelection().toString() !== '') return;
if (defaultStore.state.useReactionPickerForContextMenu) {
ev.preventDefault();
react();
} else {
os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), ev).then(focus);
}
}
function menu(viaKeyboard = false): void {
os.popupMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), menuButton.value, {
viaKeyboard
}).then(focus);
}
function showRenoteMenu(viaKeyboard = false): void {
if (!isMyRenote) return;
os.popupMenu([{
text: i18n.locale.unrenote,
icon: 'fas fa-trash-alt',
danger: true,
action: () => {
os.api('notes/delete', {
noteId: props.note.id
});
isDeleted.value = true;
}
}], renoteTime.value, {
viaKeyboard: viaKeyboard
});
}
function focus() {
el.value.focus();
}
function blur() {
el.value.blur();
}
os.api('notes/children', {
noteId: appearNote.id,
limit: 30
}).then(res => {
replies.value = res;
});
if (appearNote.replyId) {
os.api('notes/conversation', {
noteId: appearNote.replyId
}).then(res => {
conversation.value = res.reverse();
});
}
</script>
<style lang="scss" scoped>

View File

@ -19,30 +19,16 @@
</header>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
import * as misskey from 'misskey-js';
import { notePage } from '@/filters/note';
import { userPage } from '@/filters/user';
import * as os from '@/os';
export default defineComponent({
props: {
note: {
type: Object,
required: true
},
},
data() {
return {
};
},
methods: {
notePage,
userPage
}
});
defineProps<{
note: misskey.entities.Note;
pinned?: boolean;
}>();
</script>
<style lang="scss" scoped>

View File

@ -14,20 +14,12 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
export default defineComponent({
components: {
},
props: {
text: {
type: String,
required: true
}
},
});
const props = defineProps<{
text: string;
}>();
</script>
<style lang="scss" scoped>

View File

@ -9,40 +9,26 @@
<XCwButton v-model="showContent" :note="note"/>
</p>
<div v-show="note.cw == null || showContent" class="content">
<XSubNote-content class="text" :note="note"/>
<MkNoteSubNoteContent class="text" :note="note"/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
import * as misskey from 'misskey-js';
import XNoteHeader from './note-header.vue';
import XSubNoteContent from './sub-note-content.vue';
import MkNoteSubNoteContent from './sub-note-content.vue';
import XCwButton from './cw-button.vue';
import * as os from '@/os';
export default defineComponent({
components: {
XNoteHeader,
XSubNoteContent,
XCwButton,
},
const props = defineProps<{
note: misskey.entities.Note;
pinned?: boolean;
}>();
props: {
note: {
type: Object,
required: true
}
},
data() {
return {
showContent: false
};
}
});
const showContent = $ref(false);
</script>
<style lang="scss" scoped>

View File

@ -2,20 +2,21 @@
<div
v-if="!muted"
v-show="!isDeleted"
ref="el"
v-hotkey="keymap"
v-size="{ max: [500, 450, 350, 300] }"
class="tkcbzcuz"
:tabindex="!isDeleted ? '-1' : null"
:class="{ renote: isRenote }"
>
<XSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
<div v-if="pinned" class="info"><i class="fas fa-thumbtack"></i> {{ $ts.pinnedNote }}</div>
<div v-if="appearNote._prId_" class="info"><i class="fas fa-bullhorn"></i> {{ $ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ $ts.hideThisNote }} <i class="fas fa-times"></i></button></div>
<div v-if="appearNote._featuredId_" class="info"><i class="fas fa-bolt"></i> {{ $ts.featured }}</div>
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
<div v-if="pinned" class="info"><i class="fas fa-thumbtack"></i> {{ i18n.locale.pinnedNote }}</div>
<div v-if="appearNote._prId_" class="info"><i class="fas fa-bullhorn"></i> {{ i18n.locale.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.locale.hideThisNote }} <i class="fas fa-times"></i></button></div>
<div v-if="appearNote._featuredId_" class="info"><i class="fas fa-bolt"></i> {{ i18n.locale.featured }}</div>
<div v-if="isRenote" class="renote">
<MkAvatar class="avatar" :user="note.user"/>
<i class="fas fa-retweet"></i>
<I18n :src="$ts.renotedBy" tag="span">
<I18n :src="i18n.locale.renotedBy" tag="span">
<template #user>
<MkA v-user-preview="note.userId" class="name" :to="userPage(note.user)">
<MkUserName :user="note.user"/>
@ -47,7 +48,7 @@
</p>
<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed }">
<div class="text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ $ts.private }})</span>
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.locale.private }})</span>
<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
<a v-if="appearNote.renote != null" class="rp">RN:</a>
@ -66,7 +67,7 @@
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/>
<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div>
<button v-if="collapsed" class="fade _button" @click="collapsed = false">
<span>{{ $ts.showMore }}</span>
<span>{{ i18n.locale.showMore }}</span>
</button>
</div>
<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="fas fa-satellite-dish"></i> {{ appearNote.channel.name }}</MkA>
@ -93,7 +94,7 @@
</article>
</div>
<div v-else class="muted" @click="muted = false">
<I18n :src="$ts.userSaysSomething" tag="small">
<I18n :src="i18n.locale.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" class="name" :to="userPage(appearNote.user)">
<MkUserName :user="appearNote.user"/>
@ -103,11 +104,11 @@
</div>
</template>
<script lang="ts">
import { defineAsyncComponent, defineComponent, markRaw } from 'vue';
<script lang="ts" setup>
import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue';
import * as mfm from 'mfm-js';
import { sum } from '@/scripts/array';
import XSub from './note.sub.vue';
import * as misskey from 'misskey-js';
import MkNoteSub from './MkNoteSub.vue';
import XNoteHeader from './note-header.vue';
import XNoteSimple from './note-simple.vue';
import XReactionsViewer from './reactions-viewer.vue';
@ -115,744 +116,164 @@ import XMediaList from './media-list.vue';
import XCwButton from './cw-button.vue';
import XPoll from './poll.vue';
import XRenoteButton from './renote-button.vue';
import MkUrlPreview from '@/components/url-preview.vue';
import MkInstanceTicker from '@/components/instance-ticker.vue';
import { pleaseLogin } from '@/scripts/please-login';
import { focusPrev, focusNext } from '@/scripts/focus';
import { url } from '@/config';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { checkWordMute } from '@/scripts/check-word-mute';
import { userPage } from '@/filters/user';
import * as os from '@/os';
import { noteActions, noteViewInterruptors } from '@/store';
import { defaultStore, noteViewInterruptors } from '@/store';
import { reactionPicker } from '@/scripts/reaction-picker';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
export default defineComponent({
components: {
XSub,
XNoteHeader,
XNoteSimple,
XReactionsViewer,
XMediaList,
XCwButton,
XPoll,
XRenoteButton,
MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
},
inject: {
inChannel: {
default: null
},
},
props: {
note: {
type: Object,
required: true
},
pinned: {
type: Boolean,
required: false,
default: false
},
},
emits: ['update:note'],
data() {
return {
connection: null,
replies: [],
showContent: false,
collapsed: false,
isDeleted: false,
muted: false,
translation: null,
translating: false,
};
},
computed: {
rs() {
return this.$store.state.reactions;
},
keymap(): any {
return {
'r': () => this.reply(true),
'e|a|plus': () => this.react(true),
'q': () => this.$refs.renoteButton.renote(true),
'f|b': this.favorite,
'delete|ctrl+d': this.del,
'ctrl+q': this.renoteDirectly,
'up|k|shift+tab': this.focusBefore,
'down|j|tab': this.focusAfter,
'esc': this.blur,
'm|o': () => this.menu(true),
's': this.toggleShowContent,
'1': () => this.reactDirectly(this.rs[0]),
'2': () => this.reactDirectly(this.rs[1]),
'3': () => this.reactDirectly(this.rs[2]),
'4': () => this.reactDirectly(this.rs[3]),
'5': () => this.reactDirectly(this.rs[4]),
'6': () => this.reactDirectly(this.rs[5]),
'7': () => this.reactDirectly(this.rs[6]),
'8': () => this.reactDirectly(this.rs[7]),
'9': () => this.reactDirectly(this.rs[8]),
'0': () => this.reactDirectly(this.rs[9]),
};
},
isRenote(): boolean {
return (this.note.renote &&
this.note.text == null &&
this.note.fileIds.length == 0 &&
this.note.poll == null);
},
appearNote(): any {
return this.isRenote ? this.note.renote : this.note;
},
isMyNote(): boolean {
return this.$i && (this.$i.id === this.appearNote.userId);
},
isMyRenote(): boolean {
return this.$i && (this.$i.id === this.note.userId);
},
reactionsCount(): number {
return this.appearNote.reactions
? sum(Object.values(this.appearNote.reactions))
: 0;
},
urls(): string[] {
if (this.appearNote.text) {
return extractUrlFromMfm(mfm.parse(this.appearNote.text));
} else {
return null;
}
},
showTicker() {
if (this.$store.state.instanceTicker === 'always') return true;
if (this.$store.state.instanceTicker === 'remote' && this.appearNote.user.instance) return true;
return false;
}
},
async created() {
if (this.$i) {
this.connection = os.stream;
}
this.collapsed = this.appearNote.cw == null && this.appearNote.text && (
(this.appearNote.text.split('\n').length > 9) ||
(this.appearNote.text.length > 500)
);
this.muted = await checkWordMute(this.appearNote, this.$i, this.$store.state.mutedWords);
// plugin
if (noteViewInterruptors.length > 0) {
let result = this.note;
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(JSON.parse(JSON.stringify(result)));
}
this.$emit('update:note', Object.freeze(result));
}
},
mounted() {
this.capture(true);
if (this.$i) {
this.connection.on('_connected_', this.onStreamConnected);
}
},
beforeUnmount() {
this.decapture(true);
if (this.$i) {
this.connection.off('_connected_', this.onStreamConnected);
}
},
methods: {
updateAppearNote(v) {
this.$emit('update:note', Object.freeze(this.isRenote ? {
...this.note,
renote: {
...this.note.renote,
...v
}
} : {
...this.note,
...v
}));
},
readPromo() {
os.api('promo/read', {
noteId: this.appearNote.id
});
this.isDeleted = true;
},
capture(withHandler = false) {
if (this.$i) {
// TODO: このノートがストリーミング経由で流れてきた場合のみ sr する
this.connection.send(document.body.contains(this.$el) ? 'sr' : 's', { id: this.appearNote.id });
if (withHandler) this.connection.on('noteUpdated', this.onStreamNoteUpdated);
}
},
decapture(withHandler = false) {
if (this.$i) {
this.connection.send('un', {
id: this.appearNote.id
});
if (withHandler) this.connection.off('noteUpdated', this.onStreamNoteUpdated);
}
},
onStreamConnected() {
this.capture();
},
onStreamNoteUpdated(data) {
const { type, id, body } = data;
if (id !== this.appearNote.id) return;
switch (type) {
case 'reacted': {
const reaction = body.reaction;
// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
let n = {
...this.appearNote,
};
if (body.emoji) {
const emojis = this.appearNote.emojis || [];
if (!emojis.includes(body.emoji)) {
n.emojis = [...emojis, body.emoji];
}
}
// TODO: reactionsプロパティがない場合ってあったっけ なければ || {} は消せる
const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
// Increment the count
n.reactions = {
...this.appearNote.reactions,
[reaction]: currentCount + 1
};
if (body.userId === this.$i.id) {
n.myReaction = reaction;
}
this.updateAppearNote(n);
break;
}
case 'unreacted': {
const reaction = body.reaction;
// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
let n = {
...this.appearNote,
};
// TODO: reactionsプロパティがない場合ってあったっけ なければ || {} は消せる
const currentCount = (this.appearNote.reactions || {})[reaction] || 0;
// Decrement the count
n.reactions = {
...this.appearNote.reactions,
[reaction]: Math.max(0, currentCount - 1)
};
if (body.userId === this.$i.id) {
n.myReaction = null;
}
this.updateAppearNote(n);
break;
}
case 'pollVoted': {
const choice = body.choice;
// DeepではなくShallowコピーであることに注意。n.reactions[reaction] = hogeとかしないように(親からもらったデータをミューテートすることになるので)
let n = {
...this.appearNote,
};
const choices = [...this.appearNote.poll.choices];
choices[choice] = {
...choices[choice],
votes: choices[choice].votes + 1,
...(body.userId === this.$i.id ? {
isVoted: true
} : {})
};
n.poll = {
...this.appearNote.poll,
choices: choices
};
this.updateAppearNote(n);
break;
}
case 'deleted': {
this.isDeleted = true;
break;
}
}
},
reply(viaKeyboard = false) {
pleaseLogin();
os.post({
reply: this.appearNote,
animation: !viaKeyboard,
}, () => {
this.focus();
});
},
renoteDirectly() {
os.apiWithDialog('notes/create', {
renoteId: this.appearNote.id
}, undefined, (res: any) => {
os.alert({
type: 'success',
text: this.$ts.renoted,
});
}, (e: Error) => {
if (e.id === 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4') {
os.alert({
type: 'error',
text: this.$ts.cantRenote,
});
} else if (e.id === 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a') {
os.alert({
type: 'error',
text: this.$ts.cantReRenote,
});
}
});
},
react(viaKeyboard = false) {
pleaseLogin();
this.blur();
reactionPicker.show(this.$refs.reactButton, reaction => {
os.api('notes/reactions/create', {
noteId: this.appearNote.id,
reaction: reaction
});
}, () => {
this.focus();
});
},
reactDirectly(reaction) {
os.api('notes/reactions/create', {
noteId: this.appearNote.id,
reaction: reaction
});
},
undoReact(note) {
const oldReaction = note.myReaction;
if (!oldReaction) return;
os.api('notes/reactions/delete', {
noteId: note.id
});
},
favorite() {
pleaseLogin();
os.apiWithDialog('notes/favorites/create', {
noteId: this.appearNote.id
}, undefined, (res: any) => {
os.alert({
type: 'success',
text: this.$ts.favorited,
});
}, (e: Error) => {
if (e.id === 'a402c12b-34dd-41d2-97d8-4d2ffd96a1a6') {
os.alert({
type: 'error',
text: this.$ts.alreadyFavorited,
});
} else if (e.id === '6dd26674-e060-4816-909a-45ba3f4da458') {
os.alert({
type: 'error',
text: this.$ts.cantFavorite,
});
}
});
},
del() {
os.confirm({
type: 'warning',
text: this.$ts.noteDeleteConfirm,
}).then(({ canceled }) => {
if (canceled) return;
os.api('notes/delete', {
noteId: this.appearNote.id
});
});
},
delEdit() {
os.confirm({
type: 'warning',
text: this.$ts.deleteAndEditConfirm,
}).then(({ canceled }) => {
if (canceled) return;
os.api('notes/delete', {
noteId: this.appearNote.id
});
os.post({ initialNote: this.appearNote, renote: this.appearNote.renote, reply: this.appearNote.reply, channel: this.appearNote.channel });
});
},
toggleFavorite(favorite: boolean) {
os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
noteId: this.appearNote.id
});
},
toggleWatch(watch: boolean) {
os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', {
noteId: this.appearNote.id
});
},
toggleThreadMute(mute: boolean) {
os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', {
noteId: this.appearNote.id
});
},
getMenu() {
let menu;
if (this.$i) {
const statePromise = os.api('notes/state', {
noteId: this.appearNote.id
});
menu = [{
icon: 'fas fa-copy',
text: this.$ts.copyContent,
action: this.copyContent
}, {
icon: 'fas fa-link',
text: this.$ts.copyLink,
action: this.copyLink
}, (this.appearNote.url || this.appearNote.uri) ? {
icon: 'fas fa-external-link-square-alt',
text: this.$ts.showOnRemote,
action: () => {
window.open(this.appearNote.url || this.appearNote.uri, '_blank');
}
} : undefined,
{
icon: 'fas fa-share-alt',
text: this.$ts.share,
action: this.share
},
this.$instance.translatorAvailable ? {
icon: 'fas fa-language',
text: this.$ts.translate,
action: this.translate
} : undefined,
null,
statePromise.then(state => state.isFavorited ? {
icon: 'fas fa-star',
text: this.$ts.unfavorite,
action: () => this.toggleFavorite(false)
} : {
icon: 'fas fa-star',
text: this.$ts.favorite,
action: () => this.toggleFavorite(true)
}),
{
icon: 'fas fa-paperclip',
text: this.$ts.clip,
action: () => this.clip()
},
(this.appearNote.userId != this.$i.id) ? statePromise.then(state => state.isWatching ? {
icon: 'fas fa-eye-slash',
text: this.$ts.unwatch,
action: () => this.toggleWatch(false)
} : {
icon: 'fas fa-eye',
text: this.$ts.watch,
action: () => this.toggleWatch(true)
}) : undefined,
statePromise.then(state => state.isMutedThread ? {
icon: 'fas fa-comment-slash',
text: this.$ts.unmuteThread,
action: () => this.toggleThreadMute(false)
} : {
icon: 'fas fa-comment-slash',
text: this.$ts.muteThread,
action: () => this.toggleThreadMute(true)
}),
this.appearNote.userId == this.$i.id ? (this.$i.pinnedNoteIds || []).includes(this.appearNote.id) ? {
icon: 'fas fa-thumbtack',
text: this.$ts.unpin,
action: () => this.togglePin(false)
} : {
icon: 'fas fa-thumbtack',
text: this.$ts.pin,
action: () => this.togglePin(true)
} : undefined,
/*
...(this.$i.isModerator || this.$i.isAdmin ? [
null,
{
icon: 'fas fa-bullhorn',
text: this.$ts.promote,
action: this.promote
}]
: []
),*/
...(this.appearNote.userId != this.$i.id ? [
null,
{
icon: 'fas fa-exclamation-circle',
text: this.$ts.reportAbuse,
action: () => {
const u = `${url}/notes/${this.appearNote.id}`;
os.popup(import('@/components/abuse-report-window.vue'), {
user: this.appearNote.user,
initialComment: `Note: ${u}\n-----\n`
}, {}, 'closed');
}
}]
: []
),
...(this.appearNote.userId == this.$i.id || this.$i.isModerator || this.$i.isAdmin ? [
null,
this.appearNote.userId == this.$i.id ? {
icon: 'fas fa-edit',
text: this.$ts.deleteAndEdit,
action: this.delEdit
} : undefined,
{
icon: 'fas fa-trash-alt',
text: this.$ts.delete,
danger: true,
action: this.del
}]
: []
)]
.filter(x => x !== undefined);
} else {
menu = [{
icon: 'fas fa-copy',
text: this.$ts.copyContent,
action: this.copyContent
}, {
icon: 'fas fa-link',
text: this.$ts.copyLink,
action: this.copyLink
}, (this.appearNote.url || this.appearNote.uri) ? {
icon: 'fas fa-external-link-square-alt',
text: this.$ts.showOnRemote,
action: () => {
window.open(this.appearNote.url || this.appearNote.uri, '_blank');
}
} : undefined]
.filter(x => x !== undefined);
}
if (noteActions.length > 0) {
menu = menu.concat([null, ...noteActions.map(action => ({
icon: 'fas fa-plug',
text: action.title,
action: () => {
action.handler(this.appearNote);
}
}))]);
}
return menu;
},
onContextmenu(e) {
const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true;
if (el.parentElement) {
return isLink(el.parentElement);
}
};
if (isLink(e.target)) return;
if (window.getSelection().toString() !== '') return;
if (this.$store.state.useReactionPickerForContextMenu) {
e.preventDefault();
this.react();
} else {
os.contextMenu(this.getMenu(), e).then(this.focus);
}
},
menu(viaKeyboard = false) {
os.popupMenu(this.getMenu(), this.$refs.menuButton, {
viaKeyboard
}).then(this.focus);
},
showRenoteMenu(viaKeyboard = false) {
if (!this.isMyRenote) return;
os.popupMenu([{
text: this.$ts.unrenote,
icon: 'fas fa-trash-alt',
danger: true,
action: () => {
os.api('notes/delete', {
noteId: this.note.id
});
this.isDeleted = true;
}
}], this.$refs.renoteTime, {
viaKeyboard: viaKeyboard
});
},
toggleShowContent() {
this.showContent = !this.showContent;
},
copyContent() {
copyToClipboard(this.appearNote.text);
os.success();
},
copyLink() {
copyToClipboard(`${url}/notes/${this.appearNote.id}`);
os.success();
},
togglePin(pin: boolean) {
os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', {
noteId: this.appearNote.id
}, undefined, null, e => {
if (e.id === '72dab508-c64d-498f-8740-a8eec1ba385a') {
os.alert({
type: 'error',
text: this.$ts.pinLimitExceeded
});
}
});
},
async clip() {
const clips = await os.api('clips/list');
os.popupMenu([{
icon: 'fas fa-plus',
text: this.$ts.createNew,
action: async () => {
const { canceled, result } = await os.form(this.$ts.createNewClip, {
name: {
type: 'string',
label: this.$ts.name
},
description: {
type: 'string',
required: false,
multiline: true,
label: this.$ts.description
},
isPublic: {
type: 'boolean',
label: this.$ts.public,
default: false
}
});
if (canceled) return;
const clip = await os.apiWithDialog('clips/create', result);
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
}
}, null, ...clips.map(clip => ({
text: clip.name,
action: () => {
os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: this.appearNote.id });
}
}))], this.$refs.menuButton, {
}).then(this.focus);
},
async promote() {
const { canceled, result: days } = await os.inputNumber({
title: this.$ts.numberOfDays,
});
if (canceled) return;
os.apiWithDialog('admin/promo/create', {
noteId: this.appearNote.id,
expiresAt: Date.now() + (86400000 * days)
});
},
share() {
navigator.share({
title: this.$t('noteOf', { user: this.appearNote.user.name }),
text: this.appearNote.text,
url: `${url}/notes/${this.appearNote.id}`
});
},
async translate() {
if (this.translation != null) return;
this.translating = true;
const res = await os.api('notes/translate', {
noteId: this.appearNote.id,
targetLang: localStorage.getItem('lang') || navigator.language,
});
this.translating = false;
this.translation = res;
},
focus() {
this.$el.focus();
},
blur() {
this.$el.blur();
},
focusBefore() {
focusPrev(this.$el);
},
focusAfter() {
focusNext(this.$el);
},
userPage
}
import { $i } from '@/account';
import { i18n } from '@/i18n';
import { getNoteMenu } from '@/scripts/get-note-menu';
import { useNoteCapture } from '@/scripts/use-note-capture';
const props = defineProps<{
note: misskey.entities.Note;
pinned?: boolean;
}>();
const inChannel = inject('inChannel', null);
const isRenote = (
props.note.renote != null &&
props.note.text == null &&
props.note.fileIds.length === 0 &&
props.note.poll == null
);
const el = ref<HTMLElement>();
const menuButton = ref<HTMLElement>();
const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
const renoteTime = ref<HTMLElement>();
const reactButton = ref<HTMLElement>();
let appearNote = $ref(isRenote ? props.note.renote as misskey.entities.Note : props.note);
const isMyRenote = $i && ($i.id === props.note.userId);
const showContent = ref(false);
const collapsed = ref(appearNote.cw == null && appearNote.text != null && (
(appearNote.text.split('\n').length > 9) ||
(appearNote.text.length > 500)
));
const isDeleted = ref(false);
const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
const translation = ref(null);
const translating = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
const keymap = {
'r': () => reply(true),
'e|a|plus': () => react(true),
'q': () => renoteButton.value.renote(true),
'up|k|shift+tab': focusBefore,
'down|j|tab': focusAfter,
'esc': blur,
'm|o': () => menu(true),
's': () => showContent.value != showContent.value,
};
useNoteCapture({
appearNote: $$(appearNote),
rootEl: el,
});
function reply(viaKeyboard = false): void {
pleaseLogin();
os.post({
reply: appearNote,
animation: !viaKeyboard,
}, () => {
focus();
});
}
function react(viaKeyboard = false): void {
pleaseLogin();
blur();
reactionPicker.show(reactButton.value, reaction => {
os.api('notes/reactions/create', {
noteId: appearNote.id,
reaction: reaction
});
}, () => {
focus();
});
}
function undoReact(note): void {
const oldReaction = note.myReaction;
if (!oldReaction) return;
os.api('notes/reactions/delete', {
noteId: note.id
});
}
function onContextmenu(ev: MouseEvent): void {
const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true;
if (el.parentElement) {
return isLink(el.parentElement);
}
};
if (isLink(ev.target)) return;
if (window.getSelection().toString() !== '') return;
if (defaultStore.state.useReactionPickerForContextMenu) {
ev.preventDefault();
react();
} else {
os.contextMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), ev).then(focus);
}
}
function menu(viaKeyboard = false): void {
os.popupMenu(getNoteMenu({ note: props.note, translating, translation, menuButton }), menuButton.value, {
viaKeyboard
}).then(focus);
}
function showRenoteMenu(viaKeyboard = false): void {
if (!isMyRenote) return;
os.popupMenu([{
text: i18n.locale.unrenote,
icon: 'fas fa-trash-alt',
danger: true,
action: () => {
os.api('notes/delete', {
noteId: props.note.id
});
isDeleted.value = true;
}
}], renoteTime.value, {
viaKeyboard: viaKeyboard
});
}
function focus() {
el.value.focus();
}
function blur() {
el.value.blur();
}
function focusBefore() {
focusPrev(el.value);
}
function focusAfter() {
focusNext(el.value);
}
function readPromo() {
os.api('promo/read', {
noteId: appearNote.id
});
isDeleted.value = true;
}
</script>
<style lang="scss" scoped>

View File

@ -1,114 +1,42 @@
<template>
<transition name="fade" mode="out-in">
<MkLoading v-if="fetching"/>
<MkError v-else-if="error" @retry="init()"/>
<div v-else-if="empty" class="_fullinfo">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<div>{{ $ts.noNotes }}</div>
</div>
<div v-else class="giivymft" :class="{ noGap }">
<div v-show="more && reversed" style="margin-bottom: var(--margin);">
<MkButton style="margin: 0 auto;" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMoreFeature">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template>
</MkButton>
<MkPagination ref="pagingComponent" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<div>{{ $ts.noNotes }}</div>
</div>
</template>
<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="reversed ? 'up' : 'down'" :reversed="reversed" :no-gap="noGap" :ad="true" class="notes">
<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note" @update:note="updated(note, $event)"/>
</XList>
<div v-show="more && !reversed" style="margin-top: var(--margin);">
<MkButton v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" style="margin: 0 auto;" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template>
</MkButton>
<template #default="{ items: notes }">
<div class="giivymft" :class="{ noGap }">
<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" :ad="true" class="notes">
<XNote :key="note._featuredId_ || note._prId_ || note.id" class="qtqtichx" :note="note"/>
</XList>
</div>
</div>
</transition>
</template>
</MkPagination>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import paging from '@/scripts/paging';
import XNote from './note.vue';
import XList from './date-separated-list.vue';
import MkButton from '@/components/ui/button.vue';
<script lang="ts" setup>
import { ref } from 'vue';
import XNote from '@/components/note.vue';
import XList from '@/components/date-separated-list.vue';
import MkPagination from '@/components/ui/pagination.vue';
import { Paging } from '@/components/ui/pagination.vue';
export default defineComponent({
components: {
XNote, XList, MkButton,
},
const props = defineProps<{
pagination: Paging;
noGap?: boolean;
}>();
mixins: [
paging({
before: (self) => {
self.$emit('before');
},
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
after: (self, e) => {
self.$emit('after', e);
}
}),
],
props: {
pagination: {
required: true
},
prop: {
type: String,
required: false
},
noGap: {
type: Boolean,
required: false,
default: false
},
},
emits: ['before', 'after'],
computed: {
notes(): any[] {
return this.prop ? this.items.map(item => item[this.prop]) : this.items;
},
reversed(): boolean {
return this.pagination.reversed;
}
},
methods: {
updated(oldValue, newValue) {
const i = this.notes.findIndex(n => n === oldValue);
if (this.prop) {
this.items[i][this.prop] = newValue;
} else {
this.items[i] = newValue;
}
},
focus() {
this.$refs.notes.focus();
}
}
defineExpose({
pagingComponent,
});
</script>
<style lang="scss" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.125s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.giivymft {
&.noGap {
> .notes {

View File

@ -1,6 +1,6 @@
<template>
<div class="mk-notification-toast" :style="{ zIndex }">
<transition name="notification-toast" appear @after-leave="$emit('closed')">
<transition :name="$store.state.animation ? 'notification-toast' : ''" appear @after-leave="$emit('closed')">
<XNotification v-if="showing" :notification="notification" class="notification _acrylic"/>
</transition>
</div>
@ -29,7 +29,7 @@ export default defineComponent({
};
},
mounted() {
setTimeout(() => {
window.setTimeout(() => {
this.showing = false;
}, 6000);
}

View File

@ -74,6 +74,7 @@ import { notePage } from '@/filters/note';
import { userPage } from '@/filters/user';
import { i18n } from '@/i18n';
import * as os from '@/os';
import { stream } from '@/stream';
import { useTooltip } from '@/scripts/use-tooltip';
export default defineComponent({
@ -106,7 +107,7 @@ export default defineComponent({
if (!props.notification.isRead) {
const readObserver = new IntersectionObserver((entries, observer) => {
if (!entries.some(entry => entry.isIntersecting)) return;
os.stream.send('readNotification', {
stream.send('readNotification', {
id: props.notification.id
});
observer.disconnect();
@ -114,7 +115,7 @@ export default defineComponent({
readObserver.observe(elRef.value);
const connection = os.stream.useChannel('main');
const connection = stream.useChannel('main');
connection.on('readAllNotifications', () => readObserver.disconnect());
onUnmounted(() => {

View File

@ -1,158 +1,77 @@
<template>
<transition name="fade" mode="out-in">
<MkLoading v-if="fetching"/>
<MkPagination ref="pagingComponent" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<div>{{ $ts.noNotifications }}</div>
</div>
</template>
<MkError v-else-if="error" @retry="init()"/>
<p v-else-if="empty" class="mfcuwfyp">{{ $ts.noNotifications }}</p>
<div v-else>
<XList v-slot="{ item: notification }" class="elsfgstc" :items="items" :no-gap="true">
<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" @update:note="noteUpdated(notification.note, $event)"/>
<template #default="{ items: notifications }">
<XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true">
<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/>
</XList>
<MkButton v-show="more" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" primary style="margin: var(--margin) auto;" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template>
</MkButton>
</div>
</transition>
</template>
</MkPagination>
</template>
<script lang="ts">
import { defineComponent, PropType, markRaw } from 'vue';
import paging from '@/scripts/paging';
import XNotification from './notification.vue';
import XList from './date-separated-list.vue';
import XNote from './note.vue';
<script lang="ts" setup>
import { defineComponent, PropType, markRaw, onUnmounted, onMounted, computed, ref } from 'vue';
import { notificationTypes } from 'misskey-js';
import MkPagination from '@/components/ui/pagination.vue';
import { Paging } from '@/components/ui/pagination.vue';
import XNotification from '@/components/notification.vue';
import XList from '@/components/date-separated-list.vue';
import XNote from '@/components/note.vue';
import * as os from '@/os';
import MkButton from '@/components/ui/button.vue';
import { stream } from '@/stream';
import { $i } from '@/account';
export default defineComponent({
components: {
XNotification,
XList,
XNote,
MkButton,
},
const props = defineProps<{
includeTypes?: PropType<typeof notificationTypes[number][]>;
unreadOnly?: boolean;
}>();
mixins: [
paging({}),
],
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
props: {
includeTypes: {
type: Array as PropType<typeof notificationTypes[number][]>,
required: false,
default: null,
},
unreadOnly: {
type: Boolean,
required: false,
default: false,
},
},
const allIncludeTypes = computed(() => props.includeTypes ?? notificationTypes.filter(x => !$i.mutingNotificationTypes.includes(x)));
data() {
return {
connection: null,
pagination: {
endpoint: 'i/notifications',
limit: 10,
params: () => ({
includeTypes: this.allIncludeTypes || undefined,
unreadOnly: this.unreadOnly,
})
},
};
},
const pagination: Paging = {
endpoint: 'i/notifications' as const,
limit: 10,
params: computed(() => ({
includeTypes: allIncludeTypes.value || undefined,
unreadOnly: props.unreadOnly,
})),
};
computed: {
allIncludeTypes() {
return this.includeTypes ?? notificationTypes.filter(x => !this.$i.mutingNotificationTypes.includes(x));
}
},
watch: {
includeTypes: {
handler() {
this.reload();
},
deep: true
},
unreadOnly: {
handler() {
this.reload();
},
},
// TODO: vue/vuexのバグか仕様かは不明なものの、プロフィール更新するなどして $i が更新されると、
// mutingNotificationTypes に変化が無くてもこのハンドラーが呼び出され無駄なリロードが発生するのを直す
'$i.mutingNotificationTypes': {
handler() {
if (this.includeTypes === null) {
this.reload();
}
},
deep: true
}
},
mounted() {
this.connection = markRaw(os.stream.useChannel('main'));
this.connection.on('notification', this.onNotification);
},
beforeUnmount() {
this.connection.dispose();
},
methods: {
onNotification(notification) {
const isMuted = !this.allIncludeTypes.includes(notification.type);
if (isMuted || document.visibilityState === 'visible') {
os.stream.send('readNotification', {
id: notification.id
});
}
if (!isMuted) {
this.prepend({
...notification,
isRead: document.visibilityState === 'visible'
});
}
},
noteUpdated(oldValue, newValue) {
const i = this.items.findIndex(n => n.note === oldValue);
this.items[i] = {
...this.items[i],
note: newValue
};
},
const onNotification = (notification) => {
const isMuted = !allIncludeTypes.value.includes(notification.type);
if (isMuted || document.visibilityState === 'visible') {
stream.send('readNotification', {
id: notification.id
});
}
if (!isMuted) {
pagingComponent.value.prepend({
...notification,
isRead: document.visibilityState === 'visible'
});
}
};
onMounted(() => {
const connection = stream.useChannel('main');
connection.on('notification', onNotification);
onUnmounted(() => {
connection.dispose();
});
});
</script>
<style lang="scss" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.125s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.mfcuwfyp {
margin: 0;
padding: 16px;
text-align: center;
color: var(--fg);
}
.elsfgstc {
background: var(--panel);
}

View File

@ -0,0 +1,108 @@
<template>
<div class="igpposuu _monospace">
<div v-if="value === null" class="null">null</div>
<div v-else-if="typeof value === 'boolean'" class="boolean">{{ value ? 'true' : 'false' }}</div>
<div v-else-if="typeof value === 'string'" class="string">"{{ value }}"</div>
<div v-else-if="typeof value === 'number'" class="number">{{ number(value) }}</div>
<div v-else-if="Array.isArray(value)" class="array">
<button @click="collapsed_ = !collapsed_">[ {{ collapsed_ ? '+' : '-' }} ]</button>
<template v-if="!collapsed_">
<div v-for="i in value.length" class="element">
{{ i }}: <XValue :value="value[i - 1]" collapsed/>
</div>
</template>
</div>
<div v-else-if="typeof value === 'object'" class="object">
<button @click="collapsed_ = !collapsed_">{ {{ collapsed_ ? '+' : '-' }} }</button>
<template v-if="!collapsed_">
<div v-for="k in Object.keys(value)" class="kv">
<div class="k">{{ k }}:</div>
<div class="v"><XValue :value="value[k]" collapsed/></div>
</div>
</template>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, ref } from 'vue';
import number from '@/filters/number';
export default defineComponent({
name: 'XValue',
props: {
value: {
type: Object,
required: true,
},
collapsed: {
type: Boolean,
required: false,
default: false,
},
},
setup(props) {
const collapsed_ = ref(props.collapsed);
return {
number,
collapsed_,
};
}
});
</script>
<style lang="scss" scoped>
.igpposuu {
display: inline;
> .null {
display: inline;
opacity: 0.7;
}
> .boolean {
display: inline;
color: var(--codeBoolean);
}
> .string {
display: inline;
color: var(--codeString);
}
> .number {
display: inline;
color: var(--codeNumber);
}
> .array {
display: inline;
> .element {
display: block;
padding-left: 16px;
}
}
> .object {
display: inline;
> .kv {
display: block;
padding-left: 16px;
> .k {
display: inline;
margin-right: 8px;
}
> .v {
display: inline;
}
}
}
}
</style>

View File

@ -0,0 +1,33 @@
<template>
<div class="zhyxdalp">
<XValue :value="value" :collapsed="false"/>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from 'vue';
import XValue from './object-view.value.vue';
export default defineComponent({
components: {
XValue
},
props: {
value: {
type: Object,
required: true,
},
},
setup(props) {
}
});
</script>
<style lang="scss" scoped>
.zhyxdalp {
}
</style>

View File

@ -3,7 +3,7 @@
<p v-if="choices.length < 2" class="caution">
<i class="fas fa-exclamation-triangle"></i>{{ $ts._poll.noOnlyOneChoice }}
</p>
<ul ref="choices">
<ul>
<li v-for="(choice, i) in choices" :key="i">
<MkInput class="input" :model-value="choice" :placeholder="$t('_poll.choiceN', { n: i + 1 })" @update:modelValue="onInput(i, $event)">
</MkInput>
@ -14,8 +14,8 @@
</ul>
<MkButton v-if="choices.length < 10" class="add" @click="add">{{ $ts.add }}</MkButton>
<MkButton v-else class="add" disabled>{{ $ts._poll.noMore }}</MkButton>
<MkSwitch v-model="multiple">{{ $ts._poll.canMultipleVote }}</MkSwitch>
<section>
<MkSwitch v-model="multiple">{{ $ts._poll.canMultipleVote }}</MkSwitch>
<div>
<MkSelect v-model="expiration">
<template #label>{{ $ts._poll.expiration }}</template>
@ -31,7 +31,7 @@
<template #label>{{ $ts._poll.deadlineTime }}</template>
</MkInput>
</section>
<section v-if="expiration === 'after'">
<section v-else-if="expiration === 'after'">
<MkInput v-model="after" type="number" class="input">
<template #label>{{ $ts._poll.duration }}</template>
</MkInput>
@ -47,8 +47,8 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { addTime } from '@/scripts/time';
import { formatDateTimeString } from '@/scripts/format-time-string';
import MkInput from './form/input.vue';
@ -56,131 +56,91 @@ import MkSelect from './form/select.vue';
import MkSwitch from './form/switch.vue';
import MkButton from './ui/button.vue';
export default defineComponent({
components: {
MkInput,
MkSelect,
MkSwitch,
MkButton,
},
const props = defineProps<{
modelValue: {
expiresAt: string;
expiredAfter: number;
choices: string[];
multiple: boolean;
};
}>();
const emit = defineEmits<{
(ev: 'update:modelValue', v: {
expiresAt: string;
expiredAfter: number;
choices: string[];
multiple: boolean;
}): void;
}>();
props: {
poll: {
type: Object,
required: true
const choices = ref(props.modelValue.choices);
const multiple = ref(props.modelValue.multiple);
const expiration = ref('infinite');
const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'));
const atTime = ref('00:00');
const after = ref(0);
const unit = ref('second');
if (props.modelValue.expiresAt) {
expiration.value = 'at';
atDate.value = atTime.value = props.modelValue.expiresAt;
} else if (typeof props.modelValue.expiredAfter === 'number') {
expiration.value = 'after';
after.value = props.modelValue.expiredAfter / 1000;
} else {
expiration.value = 'infinite';
}
function onInput(i, value) {
choices.value[i] = value;
}
function add() {
choices.value.push('');
// TODO
// nextTick(() => {
// (this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus();
// });
}
function remove(i) {
choices.value = choices.value.filter((_, _i) => _i != i);
}
function get() {
const calcAt = () => {
return new Date(`${atDate.value} ${atTime.value}`).getTime();
};
const calcAfter = () => {
let base = parseInt(after.value);
switch (unit.value) {
case 'day': base *= 24;
case 'hour': base *= 60;
case 'minute': base *= 60;
case 'second': return base *= 1000;
default: return null;
}
},
};
emits: ['updated'],
return {
choices: choices.value,
multiple: multiple.value,
...(
expiration.value === 'at' ? { expiresAt: calcAt() } :
expiration.value === 'after' ? { expiredAfter: calcAfter() } : {}
)
};
}
data() {
return {
choices: this.poll.choices,
multiple: this.poll.multiple,
expiration: 'infinite',
atDate: formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'),
atTime: '00:00',
after: 0,
unit: 'second',
};
},
watch: {
choices: {
handler() {
this.$emit('updated', this.get());
},
deep: true
},
multiple: {
handler() {
this.$emit('updated', this.get());
},
},
expiration: {
handler() {
this.$emit('updated', this.get());
},
},
atDate: {
handler() {
this.$emit('updated', this.get());
},
},
after: {
handler() {
this.$emit('updated', this.get());
},
},
unit: {
handler() {
this.$emit('updated', this.get());
},
},
},
created() {
const poll = this.poll;
if (poll.expiresAt) {
this.expiration = 'at';
this.atDate = this.atTime = poll.expiresAt;
} else if (typeof poll.expiredAfter === 'number') {
this.expiration = 'after';
this.after = poll.expiredAfter / 1000;
} else {
this.expiration = 'infinite';
}
},
methods: {
onInput(i, e) {
this.choices[i] = e;
},
add() {
this.choices.push('');
this.$nextTick(() => {
// TODO
//(this.$refs.choices as any).childNodes[this.choices.length - 1].childNodes[0].focus();
});
},
remove(i) {
this.choices = this.choices.filter((_, _i) => _i != i);
},
get() {
const at = () => {
return new Date(`${this.atDate} ${this.atTime}`).getTime();
};
const after = () => {
let base = parseInt(this.after);
switch (this.unit) {
case 'day': base *= 24;
case 'hour': base *= 60;
case 'minute': base *= 60;
case 'second': return base *= 1000;
default: return null;
}
};
return {
choices: this.choices,
multiple: this.multiple,
...(
this.expiration === 'at' ? { expiresAt: at() } :
this.expiration === 'after' ? { expiredAfter: after() } : {}
)
};
},
}
watch([choices, multiple, expiration, atDate, atTime, after, unit], () => emit('update:modelValue', get()), {
deep: true,
});
</script>
<style lang="scss" scoped>
.zmdxowus {
padding: 8px;
padding: 8px 16px;
> .caution {
margin: 0 0 8px 0;
@ -216,7 +176,7 @@ export default defineComponent({
}
> .add {
margin: 8px 0 0 0;
margin: 8px 0;
z-index: 1;
}
@ -225,21 +185,27 @@ export default defineComponent({
> div {
margin: 0 8px;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 12px;
&:last-child {
flex: 1 0 auto;
> section {
align-items: center;
display: flex;
margin: -32px 0 0;
> div {
flex-grow: 1;
}
> &:first-child {
margin-right: 16px;
}
> section {
// MAGIC: Prevent div above from growing unless wrapped to its own line
flex-grow: 9999;
align-items: end;
display: flex;
gap: 4px;
> .input {
flex: 1 0 auto;
flex: 1 1 auto;
}
}
}

View File

@ -10,7 +10,7 @@
</div>
</template>
</XDraggable>
<p class="remain">{{ 4 - files.length }}/4</p>
<p class="remain">{{ 16 - files.length }}/16</p>
</div>
</template>
@ -41,7 +41,6 @@ export default defineComponent({
data() {
return {
menu: null as Promise<null> | null,
};
},
@ -99,10 +98,12 @@ export default defineComponent({
}, {
done: result => {
if (!result || result.canceled) return;
let comment = result.result;
let comment = result.result.length == 0 ? null : result.result;
os.api('drive/files/update', {
fileId: file.id,
comment: comment.length == 0 ? null : comment
comment: comment,
}).then(() => {
file.comment = comment;
});
}
}, 'closed');

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +1,13 @@
<template>
<MkEmoji :emoji="reaction" :custom-emojis="customEmojis" :is-reaction="true" :normal="true" :no-style="noStyle"/>
<MkEmoji :emoji="reaction" :custom-emojis="customEmojis || []" :is-reaction="true" :normal="true" :no-style="noStyle"/>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
export default defineComponent({
props: {
reaction: {
type: String,
required: true
},
customEmojis: {
required: false,
default: () => []
},
noStyle: {
type: Boolean,
required: false,
default: false
},
},
});
const props = defineProps<{
reaction: string;
customEmojis?: any[]; // TODO
noStyle?: boolean;
}>();
</script>

View File

@ -1,5 +1,5 @@
<template>
<MkTooltip ref="tooltip" :source="source" :max-width="340" @closed="$emit('closed')">
<MkTooltip ref="tooltip" :source="source" :max-width="340" @closed="emit('closed')">
<div class="beeadbfb">
<XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/>
<div class="name">{{ reaction.replace('@.', '') }}</div>
@ -7,31 +7,20 @@
</MkTooltip>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
import MkTooltip from './ui/tooltip.vue';
import XReactionIcon from './reaction-icon.vue';
export default defineComponent({
components: {
MkTooltip,
XReactionIcon,
},
props: {
reaction: {
type: String,
required: true,
},
emojis: {
type: Array,
required: true,
},
source: {
required: true,
}
},
emits: ['closed'],
})
const props = defineProps<{
reaction: string;
emojis: any[]; // TODO
source: any; // TODO
}>();
const emit = defineEmits<{
(e: 'closed'): void;
}>();
</script>
<style lang="scss" scoped>

View File

@ -1,5 +1,5 @@
<template>
<MkTooltip ref="tooltip" :source="source" :max-width="340" @closed="$emit('closed')">
<MkTooltip ref="tooltip" :source="source" :max-width="340" @closed="emit('closed')">
<div class="bqxuuuey">
<div class="reaction">
<XReactionIcon :reaction="reaction" :custom-emojis="emojis" class="icon" :no-style="true"/>
@ -16,39 +16,22 @@
</MkTooltip>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
import MkTooltip from './ui/tooltip.vue';
import XReactionIcon from './reaction-icon.vue';
export default defineComponent({
components: {
MkTooltip,
XReactionIcon
},
props: {
reaction: {
type: String,
required: true,
},
users: {
type: Array,
required: true,
},
count: {
type: Number,
required: true,
},
emojis: {
type: Array,
required: true,
},
source: {
required: true,
}
},
emits: ['closed'],
})
const props = defineProps<{
reaction: string;
users: any[]; // TODO
count: number;
emojis: any[]; // TODO
source: any; // TODO
}>();
const emit = defineEmits<{
(e: 'closed'): void;
}>();
</script>
<style lang="scss" scoped>

View File

@ -4,31 +4,19 @@
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
<script lang="ts" setup>
import { computed } from 'vue';
import * as misskey from 'misskey-js';
import { $i } from '@/account';
import XReaction from './reactions-viewer.reaction.vue';
export default defineComponent({
components: {
XReaction
},
props: {
note: {
type: Object,
required: true
},
},
data() {
return {
initialReactions: new Set(Object.keys(this.note.reactions))
};
},
computed: {
isMe(): boolean {
return this.$i && this.$i.id === this.note.userId;
},
},
});
const props = defineProps<{
note: misskey.entities.Note;
}>();
const initialReactions = new Set(Object.keys(props.note.reactions));
const isMe = computed(() => $i && $i.id === props.note.userId);
</script>
<style lang="scss" scoped>

View File

@ -2,22 +2,10 @@
<div class="jmgmzlwq _block"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i>{{ $ts.remoteUserCaution }}<a :href="href" rel="nofollow noopener" target="_blank">{{ $ts.showOnRemote }}</a></div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import * as os from '@/os';
export default defineComponent({
props: {
href: {
type: String,
required: true
},
},
data() {
return {
};
}
});
<script lang="ts" setup>
defineProps<{
href: string;
}>();
</script>
<style lang="scss" scoped>

Some files were not shown because too many files have changed in this diff Show More