Merge branch 'develop' into feature/channel_muting

# Conflicts:
#	CHANGELOG.md
#	packages/backend/src/core/CoreModule.ts
#	packages/backend/src/server/api/endpoints/channels/timeline.ts
#	packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
#	packages/backend/src/server/api/endpoints/notes/local-timeline.ts
#	packages/backend/src/server/api/endpoints/notes/timeline.ts
#	packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts
#	packages/backend/src/server/api/endpoints/users/notes.ts
#	packages/backend/src/server/api/stream/Connection.ts
#	packages/backend/src/server/api/stream/channel.ts
#	packages/backend/src/server/api/stream/channels/channel.ts
#	packages/backend/src/server/api/stream/channels/home-timeline.ts
#	packages/backend/src/server/api/stream/channels/hybrid-timeline.ts
#	packages/backend/test/e2e/timelines.ts
This commit is contained in:
おさむのひと 2024-10-05 17:42:59 +09:00
commit 5595e8fbd1
847 changed files with 35217 additions and 13941 deletions

View file

@ -53,7 +53,6 @@ await fs.readFile(
'../../assets/**',
'../../fluent-emojis/**',
'../../locales/ja-JP.yml',
'../../misskey-assets/**',
'assets/**',
'public/**',
'../../pnpm-lock.yaml',

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { AISCRIPT_VERSION } from '@syuilo/aiscript';
import type { entities } from 'misskey-js'
export function abuseUserReport() {
@ -47,18 +48,7 @@ export function clip(id = 'someclipid', name = 'Some Clip'): entities.Clip {
createdAt: '2016-12-28T22:49:51.000Z',
lastClippedAt: null,
userId: 'someuserid',
user: {
id: 'someuserid',
name: 'Misskey User',
username: 'miskist',
host: 'misskey-hub.net',
avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay',
avatarDecorations: [],
emojis: {},
badgeRoles: [],
onlineStatus: 'unknown',
},
user: userLite(),
notesCount: undefined,
name,
description: 'Some clip description',
@ -125,6 +115,49 @@ export function file(isSensitive = false) {
};
}
const script = `/// @ ${AISCRIPT_VERSION}
var name = ""
Ui:render([
Ui:C:textInput({
label: "Your name"
onInput: @(v) { name = v }
})
Ui:C:button({
text: "Hello"
onClick: @() {
Mk:dialog(null, \`Hello, {name}!\`)
}
})
])
`;
export function flash(): entities.Flash {
return {
id: 'someflashid',
createdAt: '2016-12-28T22:49:51.000Z',
updatedAt: '2016-12-28T22:49:51.000Z',
userId: 'someuserid',
user: userLite(),
title: 'Some Play title',
summary: 'Some Play summary',
script,
visibility: 'public',
likedCount: 0,
isLiked: false,
};
}
export function folder(id = 'somefolderid', name = 'Some Folder', parentId: string | null = null): entities.DriveFolder {
return {
id,
createdAt: '2016-12-28T22:49:51.000Z',
name,
parentId,
};
}
export function federationInstance(): entities.FederationInstance {
return {
id: 'someinstanceid',
@ -154,7 +187,27 @@ export function federationInstance(): entities.FederationInstance {
};
}
export function userDetailed(id = 'someuserid', username = 'miskist', host = 'misskey-hub.net', name = 'Misskey User'): entities.UserDetailed {
export function note(id = 'somenoteid'): entities.Note {
return {
id,
createdAt: '2016-12-28T22:49:51.000Z',
deletedAt: null,
text: 'some note',
cw: null,
userId: 'someuserid',
user: userLite(),
visibility: 'public',
reactionAcceptance: 'nonSensitiveOnly',
reactionEmojis: {},
reactions: {},
myReaction: null,
reactionCount: 0,
renoteCount: 0,
repliesCount: 0,
};
}
export function userLite(id = 'someuserid', username = 'miskist', host: entities.UserDetailed['host'] = 'misskey-hub.net', name: entities.UserDetailed['name'] = 'Misskey User'): entities.UserLite {
return {
id,
username,
@ -165,6 +218,12 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay',
avatarDecorations: [],
emojis: {},
};
}
export function userDetailed(id = 'someuserid', username = 'miskist', host: entities.UserDetailed['host'] = 'misskey-hub.net', name: entities.UserDetailed['name'] = 'Misskey User'): entities.UserDetailed {
return {
...userLite(id, username, host, name),
bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog',
bannerUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
birthday: '2014-06-20',
@ -215,7 +274,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
movedTo: null,
alsoKnownAs: null,
notify: 'none',
memo: null
memo: null,
};
}

View file

@ -397,15 +397,28 @@ function toStories(component: string): Promise<string> {
const globs = await Promise.all([
glob('src/components/global/Mk*.vue'),
glob('src/components/global/RouterView.vue'),
glob('src/components/Mk[A-C]*.vue'),
glob('src/components/MkDigitalClock.vue'),
glob('src/components/MkAbuseReportWindow.vue'),
glob('src/components/MkAccountMoved.vue'),
glob('src/components/MkAchievements.vue'),
glob('src/components/MkAnalogClock.vue'),
glob('src/components/MkAnimBg.vue'),
glob('src/components/MkAnnouncementDialog.vue'),
glob('src/components/MkAntennaEditor.vue'),
glob('src/components/MkAntennaEditorDialog.vue'),
glob('src/components/MkAsUi.vue'),
glob('src/components/MkAutocomplete.vue'),
glob('src/components/MkAvatars.vue'),
glob('src/components/Mk[B-E]*.vue'),
glob('src/components/MkFlashPreview.vue'),
glob('src/components/MkGalleryPostPreview.vue'),
glob('src/components/MkSignupServerRules.vue'),
glob('src/components/MkUserSetupDialog.vue'),
glob('src/components/MkUserSetupDialog.*.vue'),
glob('src/components/MkInstanceCardMini.vue'),
glob('src/components/MkInviteCode.vue'),
glob('src/pages/admin/overview.ap-requests.vue'),
glob('src/pages/user/home.vue'),
glob('src/pages/search.vue'),
]);
const components = globs.flat();
await Promise.all(components.map(async (component) => {

View file

@ -30,7 +30,7 @@ const keys = [
'd-u0',
]
await Promise.all(keys.map((key) => readFile(new URL(`../src/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => {
await Promise.all(keys.map((key) => readFile(new URL(`../../frontend-shared/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => {
writeFile(
new URL('./themes.ts', import.meta.url),
`export default ${JSON.stringify(

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
declare module '@/themes/*.json5' {
declare module '@@/themes/*.json5' {
import { Theme } from '@/scripts/theme.js';
const theme: Theme;

View file

@ -17,35 +17,35 @@
"lint": "pnpm typecheck && pnpm eslint"
},
"dependencies": {
"@discordapp/twemoji": "15.0.3",
"@discordapp/twemoji": "15.1.0",
"@github/webauthn-json": "2.1.1",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@misskey-dev/browser-image-resizer": "2024.1.0",
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "5.0.7",
"@rollup/pluginutils": "5.1.0",
"@syuilo/aiscript": "0.18.0",
"@rollup/pluginutils": "5.1.2",
"@syuilo/aiscript": "0.19.0",
"@tabler/icons-webfont": "3.3.0",
"@twemoji/parser": "15.1.1",
"@vitejs/plugin-vue": "5.0.5",
"@vue/compiler-sfc": "3.4.31",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.9",
"astring": "1.8.6",
"@vitejs/plugin-vue": "5.1.4",
"@vue/compiler-sfc": "3.5.11",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.11",
"astring": "1.9.0",
"broadcast-channel": "7.0.0",
"buraha": "0.0.1",
"canvas-confetti": "1.9.3",
"chart.js": "4.4.3",
"chart.js": "4.4.4",
"chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-matrix": "2.0.1",
"chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.0.1",
"chromatic": "11.5.4",
"compare-versions": "6.1.0",
"cropperjs": "2.0.0-beta.5",
"chromatic": "11.11.0",
"compare-versions": "6.1.1",
"cropperjs": "2.0.0-rc.2",
"date-fns": "2.30.0",
"escape-regexp": "0.0.1",
"estree-walker": "3.0.3",
"eventemitter3": "5.0.1",
"frontend-shared": "workspace:*",
"idb-keyval": "6.2.1",
"insert-text-at-cursor": "0.3.0",
"is-file-animated": "1.0.2",
@ -57,85 +57,85 @@
"misskey-reversi": "workspace:*",
"photoswipe": "5.4.4",
"punycode": "2.3.1",
"rollup": "4.18.0",
"sanitize-html": "2.13.0",
"sass": "1.77.6",
"shiki": "1.10.0",
"rollup": "4.22.5",
"sanitize-html": "2.13.1",
"sass": "1.79.3",
"shiki": "1.21.0",
"strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0",
"three": "0.165.0",
"three": "0.169.0",
"throttle-debounce": "5.0.2",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.10",
"tsconfig-paths": "4.2.0",
"typescript": "5.5.3",
"typescript": "5.6.2",
"uuid": "10.0.0",
"v-code-diff": "1.12.0",
"vite": "5.3.2",
"vue": "3.4.31",
"v-code-diff": "1.13.1",
"vite": "5.4.8",
"vue": "3.5.11",
"vuedraggable": "next"
},
"devDependencies": {
"@misskey-dev/summaly": "5.1.0",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-essentials": "8.1.11",
"@storybook/addon-interactions": "8.1.11",
"@storybook/addon-links": "8.1.11",
"@storybook/addon-mdx-gfm": "8.1.11",
"@storybook/addon-storysource": "8.1.11",
"@storybook/blocks": "8.1.11",
"@storybook/components": "8.1.11",
"@storybook/core-events": "8.1.11",
"@storybook/manager-api": "8.1.11",
"@storybook/preview-api": "8.1.11",
"@storybook/react": "8.1.11",
"@storybook/react-vite": "8.1.11",
"@storybook/test": "8.1.11",
"@storybook/theming": "8.1.11",
"@storybook/types": "8.1.11",
"@storybook/vue3": "8.1.11",
"@storybook/vue3-vite": "8.1.11",
"@storybook/addon-actions": "8.3.4",
"@storybook/addon-essentials": "8.3.4",
"@storybook/addon-interactions": "8.3.4",
"@storybook/addon-links": "8.3.4",
"@storybook/addon-mdx-gfm": "8.3.4",
"@storybook/addon-storysource": "8.3.4",
"@storybook/blocks": "8.3.4",
"@storybook/components": "8.3.4",
"@storybook/core-events": "8.3.4",
"@storybook/manager-api": "8.3.4",
"@storybook/preview-api": "8.3.4",
"@storybook/react": "8.3.4",
"@storybook/react-vite": "8.3.4",
"@storybook/test": "8.3.4",
"@storybook/theming": "8.3.4",
"@storybook/types": "8.3.4",
"@storybook/vue3": "8.3.4",
"@storybook/vue3-vite": "8.3.4",
"@testing-library/vue": "8.1.0",
"@types/escape-regexp": "0.0.3",
"@types/estree": "1.0.5",
"@types/matter-js": "0.19.6",
"@types/canvas-confetti": "^1.6.4",
"@types/estree": "1.0.6",
"@types/matter-js": "0.19.7",
"@types/micromatch": "4.0.9",
"@types/node": "20.14.9",
"@types/node": "20.14.12",
"@types/punycode": "2.1.4",
"@types/sanitize-html": "2.11.0",
"@types/sanitize-html": "2.13.0",
"@types/seedrandom": "3.0.8",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/uuid": "10.0.0",
"@types/ws": "8.5.10",
"@typescript-eslint/eslint-plugin": "7.15.0",
"@typescript-eslint/parser": "7.15.0",
"@types/ws": "8.5.12",
"@typescript-eslint/eslint-plugin": "7.17.0",
"@typescript-eslint/parser": "7.17.0",
"@vitest/coverage-v8": "1.6.0",
"@vue/runtime-core": "3.4.31",
"acorn": "8.12.0",
"@vue/runtime-core": "3.5.11",
"acorn": "8.12.1",
"cross-env": "7.0.3",
"cypress": "13.13.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-vue": "9.26.0",
"cypress": "13.15.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-vue": "9.28.0",
"fast-glob": "3.3.2",
"happy-dom": "10.0.3",
"intersection-observer": "0.12.2",
"micromatch": "4.0.7",
"msw": "2.3.1",
"msw-storybook-addon": "2.0.2",
"nodemon": "3.1.4",
"prettier": "3.3.2",
"micromatch": "4.0.8",
"msw": "2.4.9",
"msw-storybook-addon": "2.0.3",
"nodemon": "3.1.7",
"prettier": "3.3.3",
"react": "18.3.1",
"react-dom": "18.3.1",
"seedrandom": "3.0.5",
"start-server-and-test": "2.0.4",
"storybook": "8.1.11",
"start-server-and-test": "2.0.8",
"storybook": "8.3.4",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "1.6.0",
"vitest-fetch-mock": "0.2.2",
"vue-component-type-helpers": "2.0.24",
"vue-component-type-helpers": "2.1.6",
"vue-eslint-parser": "9.4.3",
"vue-tsc": "2.0.24"
"vue-tsc": "2.1.6"
}
}

View file

@ -6,6 +6,8 @@
// https://vitejs.dev/config/build-options.html#build-modulepreload
import 'vite/modulepreload-polyfill';
import '@tabler/icons-webfont/dist/tabler-icons.scss';
import '@/style.scss';
import { mainBoot } from '@/boot/main-boot.js';
import { subBoot } from '@/boot/sub-boot.js';

View file

@ -3,11 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
// devモードで起動される際index.htmlを使うときはrouterが暴発してしまってうまく読み込めない。
// よって、devモードとして起動されるときはビルド時に組み込む形としておく。
// (pnpm start時はpugファイルの中で静的リソースとして読み込むようになっており、この問題は起こっていない)
import '@tabler/icons-webfont/dist/tabler-icons.scss';
await main();
import('@/_boot_.js');

View file

@ -8,9 +8,9 @@ import * as Misskey from 'misskey-js';
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
import { MenuButton } from '@/types/menu.js';
import type { MenuItem, MenuButton } from '@/types/menu.js';
import { del, get, set } from '@/scripts/idb-proxy.js';
import { apiUrl } from '@/config.js';
import { apiUrl } from '@@/js/config.js';
import { waiting, popup, popupMenu, success, alert } from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { unisonReload, reloadChannel } from '@/scripts/unison-reload.js';
@ -288,14 +288,26 @@ export async function openAccountMenu(opts: {
});
}));
const menuItems: MenuItem[] = [];
if (opts.withExtraOperation) {
popupMenu([...[{
type: 'link' as const,
menuItems.push({
type: 'link',
text: i18n.ts.profile,
to: `/@${ $i.username }`,
to: `/@${$i.username}`,
avatar: $i,
}, { type: 'divider' as const }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
type: 'parent' as const,
}, {
type: 'divider',
});
if (opts.includeCurrentAccount) {
menuItems.push(createItem($i));
}
menuItems.push(...accountItemPromises);
menuItems.push({
type: 'parent',
icon: 'ti ti-plus',
text: i18n.ts.addAccount,
children: [{
@ -306,18 +318,22 @@ export async function openAccountMenu(opts: {
action: () => { createAccount(); },
}],
}, {
type: 'link' as const,
type: 'link',
icon: 'ti ti-users',
text: i18n.ts.manageAccounts,
to: '/settings/accounts',
}]], ev.currentTarget ?? ev.target, {
align: 'left',
});
} else {
popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, {
align: 'left',
});
if (opts.includeCurrentAccount) {
menuItems.push(createItem($i));
}
menuItems.push(...accountItemPromises);
}
popupMenu(menuItems, ev.currentTarget ?? ev.target, {
align: 'left',
});
}
if (_DEV_) {

View file

@ -5,10 +5,10 @@
import { computed, watch, version as vueVersion, App } from 'vue';
import { compareVersions } from 'compare-versions';
import { version, lang, updateLocale, locale } from '@@/js/config.js';
import widgets from '@/widgets/index.js';
import directives from '@/directives/index.js';
import components from '@/components/index.js';
import { version, lang, updateLocale, locale } from '@/config.js';
import { applyTheme } from '@/scripts/theme.js';
import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js';
import { updateI18n } from '@/i18n.js';
@ -22,7 +22,8 @@ import { getAccountFromId } from '@/scripts/get-account-from-id.js';
import { deckStore } from '@/ui/deck/deck-store.js';
import { miLocalStorage } from '@/local-storage.js';
import { fetchCustomEmojis } from '@/custom-emojis.js';
import { setupRouter } from '@/router/definition.js';
import { setupRouter } from '@/router/main.js';
import { createMainRouter } from '@/router/definition.js';
export async function common(createVue: () => App<Element>) {
console.info(`Misskey v${version}`);
@ -145,10 +146,9 @@ export async function common(createVue: () => App<Element>) {
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
watch(defaultStore.reactiveState.darkMode, (darkMode) => {
applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
document.documentElement.dataset.colorMode = darkMode ? 'dark' : 'light';
}, { immediate: miLocalStorage.getItem('theme') == null });
document.documentElement.dataset.colorMode = defaultStore.state.darkMode ? 'dark' : 'light';
document.documentElement.dataset.colorScheme = defaultStore.state.darkMode ? 'dark' : 'light';
const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme'));
const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme'));
@ -239,7 +239,7 @@ export async function common(createVue: () => App<Element>) {
const app = createVue();
setupRouter(app);
setupRouter(app, createMainRouter);
if (_DEV_) {
app.config.performance = true;

View file

@ -5,7 +5,8 @@
import { createApp, defineAsyncComponent, markRaw } from 'vue';
import { common } from './common.js';
import { ui } from '@/config.js';
import type * as Misskey from 'misskey-js';
import { ui } from '@@/js/config.js';
import { i18n } from '@/i18n.js';
import { alert, confirm, popup, post, toast } from '@/os.js';
import { useStream } from '@/stream.js';
@ -13,7 +14,6 @@ import * as sound from '@/scripts/sound.js';
import { $i, signout, updateAccount } from '@/account.js';
import { instance } from '@/instance.js';
import { ColdDeviceStorage, defaultStore } from '@/store.js';
import { makeHotkey } from '@/scripts/hotkey.js';
import { reactionPicker } from '@/scripts/reaction-picker.js';
import { miLocalStorage } from '@/local-storage.js';
import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js';
@ -21,6 +21,8 @@ import { initializeSw } from '@/scripts/initialize-sw.js';
import { deckStore } from '@/ui/deck/deck-store.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
import { mainRouter } from '@/router/main.js';
import { type Keymap, makeHotkey } from '@/scripts/hotkey.js';
import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js';
export async function mainBoot() {
const { isClientUpdated } = await common(() => createApp(
@ -61,6 +63,18 @@ export async function mainBoot() {
}
});
stream.on('emojiAdded', emojiData => {
addCustomEmoji(emojiData.emoji);
});
stream.on('emojiUpdated', emojiData => {
updateCustomEmojis(emojiData.emojis);
});
stream.on('emojiDeleted', emojiData => {
removeCustomEmojis(emojiData.emojis);
});
for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) {
import('@/plugin.js').then(async ({ install }) => {
// Workaround for https://bugs.webkit.org/show_bug.cgi?id=242740
@ -69,14 +83,6 @@ export async function mainBoot() {
});
}
const hotkeys = {
'd': (): void => {
defaultStore.set('darkMode', !defaultStore.state.darkMode);
},
's': (): void => {
mainRouter.push('/search');
},
};
try {
if (defaultStore.state.enableSeasonalScreenEffect) {
const month = new Date().getMonth() + 1;
@ -105,9 +111,6 @@ export async function mainBoot() {
}
if ($i) {
// only add post shortcuts if logged in
hotkeys['p|n'] = post;
defaultStore.loaded.then(() => {
if (defaultStore.state.accountSetupWizard !== -1) {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {
@ -124,7 +127,7 @@ export async function mainBoot() {
});
}
stream.on('announcementCreated', (ev) => {
function onAnnouncementCreated (ev: { announcement: Misskey.entities.Announcement }) {
const announcement = ev.announcement;
if (announcement.display === 'dialog') {
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), {
@ -133,7 +136,9 @@ export async function mainBoot() {
closed: () => dispose(),
});
}
});
}
stream.on('announcementCreated', onAnnouncementCreated);
if ($i.isDeleted) {
alert({
@ -239,17 +244,18 @@ export async function mainBoot() {
claimAchievement('client60min');
}, 1000 * 60 * 60);
const lastUsed = miLocalStorage.getItem('lastUsed');
if (lastUsed) {
const lastUsedDate = parseInt(lastUsed, 10);
// 二時間以上前なら
if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) {
toast(i18n.tsx.welcomeBackWithName({
name: $i.name || $i.username,
}));
}
}
miLocalStorage.setItem('lastUsed', Date.now().toString());
// 邪魔
//const lastUsed = miLocalStorage.getItem('lastUsed');
//if (lastUsed) {
// const lastUsedDate = parseInt(lastUsed, 10);
// // 二時間以上前なら
// if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) {
// toast(i18n.tsx.welcomeBackWithName({
// name: $i.name || $i.username,
// }));
// }
//}
//miLocalStorage.setItem('lastUsed', Date.now().toString());
const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt');
const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo');
@ -326,6 +332,9 @@ export async function mainBoot() {
updateAccount({ hasUnreadAnnouncement: false });
});
// 個人宛てお知らせが発行されたとき
main.on('announcementCreated', onAnnouncementCreated);
// トークンが再生成されたとき
// このままではMisskeyが利用できないので強制的にサインアウトさせる
main.on('myTokenRegenerated', () => {
@ -334,7 +343,19 @@ export async function mainBoot() {
}
// shortcut
document.addEventListener('keydown', makeHotkey(hotkeys));
const keymap = {
'p|n': () => {
if ($i == null) return;
post();
},
'd': () => {
defaultStore.set('darkMode', !defaultStore.state.darkMode);
},
's': () => {
mainRouter.push('/search');
},
} as const satisfies Keymap;
document.addEventListener('keydown', makeHotkey(keymap), { passive: false });
initializeSw();
}

View file

@ -5,9 +5,12 @@
import { createApp, defineAsyncComponent } from 'vue';
import { common } from './common.js';
import { emojiPicker } from '@/scripts/emoji-picker.js';
export async function subBoot() {
const { isClientUpdated } = await common(() => createApp(
defineAsyncComponent(() => import('@/ui/minimum.vue')),
));
emojiPicker.init();
}

View file

@ -4,64 +4,99 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="bcekxzvu _margin _panel">
<div class="target">
<MkA v-user-preview="report.targetUserId" class="info" :to="`/admin/user/${report.targetUserId}`" :behavior="'window'">
<MkAvatar class="avatar" :user="report.targetUser" indicator/>
<div class="names">
<MkUserName class="name" :user="report.targetUser"/>
<MkAcct class="acct" :user="report.targetUser" style="display: block;"/>
</div>
</MkA>
<MkKeyValue>
<template #key>{{ i18n.ts.registeredDate }}</template>
<template #value>{{ dateString(report.targetUser.createdAt) }} (<MkTime :time="report.targetUser.createdAt"/>)</template>
</MkKeyValue>
</div>
<div class="detail">
<div>
<Mfm :text="report.comment" :linkNavigationBehavior="'window'"/>
<MkFolder>
<template #icon>
<i v-if="report.resolved" class="ti ti-check" style="color: var(--success)"></i>
<i v-else class="ti ti-exclamation-circle" style="color: var(--warn)"></i>
</template>
<template #label><MkAcct :user="report.targetUser"/> (by <MkAcct :user="report.reporter"/>)</template>
<template #caption>{{ report.comment }}</template>
<template #suffix><MkTime :time="report.createdAt"/></template>
<template v-if="!report.resolved" #footer>
<div class="_buttons">
<MkButton primary @click="resolve">{{ i18n.ts.abuseMarkAsResolved }}</MkButton>
<template v-if="report.targetUser.host == null || report.resolved">
<MkButton primary @click="resolveAndForward">{{ i18n.ts.forwardReport }}</MkButton>
<div v-tooltip:dialog="i18n.ts.forwardReportIsAnonymous" class="_button _help"><i class="ti ti-help-circle"></i></div>
</template>
</div>
<hr/>
<div>{{ i18n.ts.reporter }}: <MkA :to="`/admin/user/${report.reporter.id}`" class="_link" :behavior="'window'">@{{ report.reporter.username }}</MkA></div>
</template>
<div :class="$style.root" class="_gaps_s">
<MkFolder :withSpacer="false">
<template #icon><MkAvatar :user="report.targetUser" style="width: 18px; height: 18px;"/></template>
<template #label>Target: <MkAcct :user="report.targetUser"/></template>
<template #suffix>#{{ report.targetUserId.toUpperCase() }}</template>
<div style="container-type: inline-size;">
<RouterView :router="targetRouter"/>
</div>
</MkFolder>
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-message-2"></i></template>
<template #label>{{ i18n.ts.details }}</template>
<div>
<Mfm :text="report.comment" :linkNavigationBehavior="'window'"/>
</div>
</MkFolder>
<MkFolder :withSpacer="false">
<template #icon><MkAvatar :user="report.reporter" style="width: 18px; height: 18px;"/></template>
<template #label>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></template>
<template #suffix>#{{ report.reporterId.toUpperCase() }}</template>
<div style="container-type: inline-size;">
<RouterView :router="reporterRouter"/>
</div>
</MkFolder>
<div v-if="report.assignee">
{{ i18n.ts.moderator }}:
<MkAcct :user="report.assignee"/>
</div>
<div><MkTime :time="report.createdAt"/></div>
<div class="action">
<MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved">
{{ i18n.ts.forwardReport }}
<template #caption>{{ i18n.ts.forwardReportIsAnonymous }}</template>
</MkSwitch>
<MkButton v-if="!report.resolved" primary @click="resolve">{{ i18n.ts.abuseMarkAsResolved }}</MkButton>
</div>
</div>
</div>
</MkFolder>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { provide, ref } from 'vue';
import * as Misskey from 'misskey-js';
import MkButton from '@/components/MkButton.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { dateString } from '@/filters/date.js';
import MkFolder from '@/components/MkFolder.vue';
import RouterView from '@/components/global/RouterView.vue';
import { useRouterFactory } from '@/router/supplier';
const props = defineProps<{
report: any;
report: Misskey.entities.AdminAbuseUserReportsResponse[number];
}>();
const emit = defineEmits<{
(ev: 'resolved', reportId: string): void;
}>();
const forward = ref(props.report.forwarded);
const routerFactory = useRouterFactory();
const targetRouter = routerFactory(`/admin/user/${props.report.targetUserId}`);
targetRouter.init();
const reporterRouter = routerFactory(`/admin/user/${props.report.reporterId}`);
reporterRouter.init();
function resolve() {
os.apiWithDialog('admin/resolve-abuse-user-report', {
forward: forward.value,
reportId: props.report.id,
}).then(() => {
emit('resolved', props.report.id);
});
}
function resolveAndForward() {
os.apiWithDialog('admin/resolve-abuse-user-report', {
forward: true,
reportId: props.report.id,
}).then(() => {
emit('resolved', props.report.id);
@ -69,47 +104,7 @@ function resolve() {
}
</script>
<style lang="scss" scoped>
.bcekxzvu {
display: flex;
> .target {
width: 35%;
box-sizing: border-box;
text-align: left;
padding: 24px;
border-right: solid 1px var(--divider);
> .info {
display: flex;
box-sizing: border-box;
align-items: center;
padding: 14px;
border-radius: 8px;
--c: rgb(255 196 0 / 15%);
background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%);
background-size: 16px 16px;
> .avatar {
width: 42px;
height: 42px;
}
> .names {
margin-left: 0.3em;
padding: 0 8px;
flex: 1;
> .name {
font-weight: bold;
}
}
}
}
> .detail {
flex: 1;
padding: 24px;
}
<style lang="scss" module>
.root {
}
</style>

View file

@ -39,7 +39,7 @@ import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
const props = defineProps<{
user: Misskey.entities.UserDetailed;
user: Misskey.entities.UserLite;
initialComment?: string;
}>();

View file

@ -16,7 +16,7 @@ import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import MkMention from './MkMention.vue';
import { i18n } from '@/i18n.js';
import { host as localHost } from '@/config.js';
import { host as localHost } from '@@/js/config.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
const user = ref<Misskey.entities.UserLite>();

View file

@ -153,7 +153,7 @@ onMounted(() => {
background: linear-gradient(0deg, #ffee20, #eb7018);
}
&:before {
&::before {
content: "";
display: block;
position: absolute;
@ -173,7 +173,7 @@ onMounted(() => {
background: linear-gradient(0deg, #e1e1e1, #7c7c7c);
}
&:before {
&::before {
content: "";
display: block;
position: absolute;

View file

@ -7,14 +7,13 @@
import { action } from '@storybook/addon-actions';
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { abuseUserReport } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkAbuseReport from './MkAbuseReport.vue';
import MkAntennaEditor from './MkAntennaEditor.vue';
export const Default = {
render(args) {
return {
components: {
MkAbuseReport,
MkAntennaEditor,
},
setup() {
return {
@ -29,26 +28,35 @@ export const Default = {
},
events() {
return {
resolved: action('resolved'),
created: action('created'),
updated: action('updated'),
deleted: action('deleted'),
};
},
},
template: '<MkAbuseReport v-bind="props" v-on="events" />',
template: '<MkAntennaEditor v-bind="props" v-on="events" />',
};
},
args: {
report: abuseUserReport(),
},
parameters: {
layout: 'fullscreen',
msw: {
handlers: [
...commonHandlers,
http.post('/api/admin/resolve-abuse-user-report', async ({ request }) => {
action('POST /api/admin/resolve-abuse-user-report')(await request.json());
http.post('/api/antennas/create', async ({ request }) => {
action('POST /api/antennas/create')(await request.json());
return HttpResponse.json({});
}),
http.post('/api/antennas/update', async ({ request }) => {
action('POST /api/antennas/update')(await request.json());
return HttpResponse.json({});
}),
http.post('/api/antennas/delete', async ({ request }) => {
action('POST /api/antennas/delete')(await request.json());
return HttpResponse.json();
}),
],
},
},
} satisfies StoryObj<typeof MkAbuseReport>;
} satisfies StoryObj<typeof MkAntennaEditor>;

View file

@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.actions">
<div class="_buttons">
<MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
<MkButton v-if="antenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
<MkButton v-if="initialAntenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</div>
</div>
@ -61,28 +61,53 @@ import MkSwitch from '@/components/MkSwitch.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { deepMerge } from '@/scripts/merge.js';
import type { DeepPartial } from '@/scripts/merge.js';
type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & {
id?: string;
createdAt?: string;
updatedAt?: string;
};
const props = defineProps<{
antenna: Misskey.entities.Antenna
antenna?: DeepPartial<PartialAllowedAntenna>;
}>();
const initialAntenna = deepMerge<PartialAllowedAntenna>(props.antenna ?? {}, {
name: '',
src: 'all',
userListId: null,
users: [],
keywords: [],
excludeKeywords: [],
excludeBots: false,
withReplies: false,
caseSensitive: false,
localOnly: false,
withFile: false,
isActive: true,
hasUnreadNote: false,
notify: false,
});
const emit = defineEmits<{
(ev: 'created'): void,
(ev: 'updated'): void,
(ev: 'created', newAntenna: Misskey.entities.Antenna): void,
(ev: 'updated', editedAntenna: Misskey.entities.Antenna): void,
(ev: 'deleted'): void,
}>();
const name = ref<string>(props.antenna.name);
const src = ref<Misskey.entities.AntennasCreateRequest['src']>(props.antenna.src);
const userListId = ref<string | null>(props.antenna.userListId);
const users = ref<string>(props.antenna.users.join('\n'));
const keywords = ref<string>(props.antenna.keywords.map(x => x.join(' ')).join('\n'));
const excludeKeywords = ref<string>(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n'));
const caseSensitive = ref<boolean>(props.antenna.caseSensitive);
const localOnly = ref<boolean>(props.antenna.localOnly);
const excludeBots = ref<boolean>(props.antenna.excludeBots);
const withReplies = ref<boolean>(props.antenna.withReplies);
const withFile = ref<boolean>(props.antenna.withFile);
const name = ref<string>(initialAntenna.name);
const src = ref<Misskey.entities.AntennasCreateRequest['src']>(initialAntenna.src);
const userListId = ref<string | null>(initialAntenna.userListId);
const users = ref<string>(initialAntenna.users.join('\n'));
const keywords = ref<string>(initialAntenna.keywords.map(x => x.join(' ')).join('\n'));
const excludeKeywords = ref<string>(initialAntenna.excludeKeywords.map(x => x.join(' ')).join('\n'));
const caseSensitive = ref<boolean>(initialAntenna.caseSensitive);
const localOnly = ref<boolean>(initialAntenna.localOnly);
const excludeBots = ref<boolean>(initialAntenna.excludeBots);
const withReplies = ref<boolean>(initialAntenna.withReplies);
const withFile = ref<boolean>(initialAntenna.withFile);
const userLists = ref<Misskey.entities.UserList[] | null>(null);
watch(() => src.value, async () => {
@ -106,24 +131,26 @@ async function saveAntenna() {
excludeKeywords: excludeKeywords.value.trim().split('\n').map(x => x.trim().split(' ')),
};
if (props.antenna.id == null) {
await os.apiWithDialog('antennas/create', antennaData);
emit('created');
if (initialAntenna.id == null) {
const res = await os.apiWithDialog('antennas/create', antennaData);
emit('created', res);
} else {
await os.apiWithDialog('antennas/update', { ...antennaData, antennaId: props.antenna.id });
emit('updated');
const res = await os.apiWithDialog('antennas/update', { ...antennaData, antennaId: initialAntenna.id });
emit('updated', res);
}
}
async function deleteAntenna() {
if (initialAntenna.id == null) return;
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.tsx.removeAreYouSure({ x: props.antenna.name }),
text: i18n.tsx.removeAreYouSure({ x: initialAntenna.name }),
});
if (canceled) return;
await misskeyApi('antennas/delete', {
antennaId: props.antenna.id,
antennaId: initialAntenna.id,
});
os.success();

View file

@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { action } from '@storybook/addon-actions';
import { StoryObj } from '@storybook/vue3';
import { HttpResponse, http } from 'msw';
import { commonHandlers } from '../../.storybook/mocks.js';
import MkAntennaEditorDialog from './MkAntennaEditorDialog.vue';
export const Default = {
render(args) {
return {
components: {
MkAntennaEditorDialog,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
created: action('created'),
updated: action('updated'),
deleted: action('deleted'),
closed: action('closed'),
};
},
},
template: '<MkAntennaEditorDialog v-bind="props" v-on="events" />',
};
},
args: {
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.post('/api/antennas/create', async ({ request }) => {
action('POST /api/antennas/create')(await request.json());
return HttpResponse.json({});
}),
http.post('/api/antennas/update', async ({ request }) => {
action('POST /api/antennas/update')(await request.json());
return HttpResponse.json({});
}),
http.post('/api/antennas/delete', async ({ request }) => {
action('POST /api/antennas/delete')(await request.json());
return HttpResponse.json();
}),
],
},
},
} satisfies StoryObj<typeof MkAntennaEditorDialog>;

View file

@ -0,0 +1,63 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:withOkButton="false"
:width="500"
:height="550"
@close="close()"
@closed="emit('closed')"
>
<template #header>{{ antenna == null ? i18n.ts.createAntenna : i18n.ts.editAntenna }}</template>
<XAntennaEditor
:antenna="antenna"
@created="onAntennaCreated"
@updated="onAntennaUpdated"
@deleted="onAntennaDeleted"
/>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { shallowRef } from 'vue';
import * as Misskey from 'misskey-js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import XAntennaEditor from '@/components/MkAntennaEditor.vue';
import { i18n } from '@/i18n.js';
defineProps<{
antenna?: Misskey.entities.Antenna;
}>();
const emit = defineEmits<{
(ev: 'created', newAntenna: Misskey.entities.Antenna): void,
(ev: 'updated', editedAntenna: Misskey.entities.Antenna): void,
(ev: 'deleted'): void,
(ev: 'closed'): void,
}>();
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
function onAntennaCreated(newAntenna: Misskey.entities.Antenna) {
emit('created', newAntenna);
dialog.value?.close();
}
function onAntennaUpdated(editedAntenna: Misskey.entities.Antenna) {
emit('updated', editedAntenna);
dialog.value?.close();
}
function onAntennaDeleted() {
emit('deleted');
dialog.value?.close();
}
function close() {
dialog.value?.close();
}
</script>

View file

@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/>
</template>
</MkFolder>
<div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }]" :style="{ textAlign: c.align, backgroundColor: c.bgColor, color: c.fgColor, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }">
<div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }]" :style="containerStyle">
<template v-for="child in c.children" :key="child">
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size" :align="c.align"/>
</template>
@ -63,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { Ref, ref } from 'vue';
import { Ref, ref, computed } from 'vue';
import * as os from '@/os.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
@ -97,6 +97,29 @@ function g(id) {
} as AsUiRoot;
}
const containerStyle = computed(() => {
if (c.type !== 'container') return undefined;
// width, color, stylewidthstyle
// radius
const isBordered = c.borderWidth ?? c.borderColor ?? c.borderStyle;
const border = isBordered ? {
borderWidth: c.borderWidth ?? '1px',
borderColor: c.borderColor ?? 'var(--divider)',
borderStyle: c.borderStyle ?? 'solid',
} : undefined;
return {
textAlign: c.align,
backgroundColor: c.bgColor,
color: c.fgColor,
padding: c.padding ? `${c.padding}px` : 0,
borderRadius: (c.borderRadius ?? (c.rounded ? 8 : 0)) + 'px',
...border,
};
});
const valueForSwitch = ref('default' in c && typeof c.default === 'boolean' ? c.default : false);
function onSwitchUpdate(v) {

View file

@ -46,17 +46,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts">
import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import sanitizeHtml from 'sanitize-html';
import { emojilist, getEmojiName } from '@@/js/emojilist.js';
import contains from '@/scripts/contains.js';
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js';
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@@/js/emoji-base.js';
import { acct } from '@/filters/user.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { defaultStore } from '@/store.js';
import { emojilist, getEmojiName } from '@/scripts/emojilist.js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js';
import { MFM_TAGS, MFM_PARAMS } from '@/const.js';
import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js';
import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js';
const lib = emojilist.filter(x => x.category !== 'flags');

View file

@ -171,11 +171,11 @@ function onMousedown(evt: MouseEvent): void {
background: var(--accent);
&:not(:disabled):hover {
background: var(--X8);
background: hsl(from var(--accent) h s calc(l + 5));
}
&:not(:disabled):active {
background: var(--X8);
background: hsl(from var(--accent) h s calc(l + 5));
}
}
@ -220,15 +220,16 @@ function onMousedown(evt: MouseEvent): void {
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
&:not(:disabled):hover {
background: linear-gradient(90deg, var(--X8), var(--X8));
background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
}
&:not(:disabled):active {
background: linear-gradient(90deg, var(--X8), var(--X8));
background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
}
}
&.danger {
font-weight: bold;
color: #ff2a2a;
&.primary {
@ -246,11 +247,10 @@ function onMousedown(evt: MouseEvent): void {
}
&:disabled {
opacity: 0.7;
opacity: 0.5;
}
&:focus-visible {
outline: solid 2px var(--focus);
outline-offset: 2px;
}

View file

@ -87,17 +87,7 @@ async function onClick() {
}
&:focus-visible {
&:after {
content: "";
pointer-events: none;
position: absolute;
top: -5px;
right: -5px;
bottom: -5px;
left: -5px;
border: 2px solid var(--focus);
border-radius: 32px;
}
outline-offset: 2px;
}
&:hover {

View file

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div style="position: relative;">
<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1" @click="updateLastReadedAt">
<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" @click="updateLastReadedAt">
<div class="banner" :style="bannerStyle">
<div class="fade"></div>
<div class="name"><i class="ti ti-device-tv"></i> {{ channel.name }}</div>
@ -80,6 +80,7 @@ const bannerStyle = computed(() => {
<style lang="scss" scoped>
.eftoefju {
display: block;
position: relative;
overflow: hidden;
width: 100%;
@ -87,6 +88,22 @@ const bannerStyle = computed(() => {
text-decoration: none;
}
&:focus-within {
outline: none;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: inherit;
pointer-events: none;
box-shadow: inset 0 0 0 2px var(--focus);
}
}
> .banner {
position: relative;
width: 100%;
@ -100,7 +117,7 @@ const bannerStyle = computed(() => {
left: 0;
width: 100%;
height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15));
background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
}
> .name {

View file

@ -13,29 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
<script lang="ts" setup>
/* eslint-disable id-denylist --
Chart.js has a `data` attribute in most chart definitions, which triggers the
id-denylist violation when setting it. This is causing about 60+ lint issues.
As this is part of Chart.js's API it makes sense to disable the check here.
*/
import { onMounted, ref, shallowRef, watch } from 'vue';
import { Chart } from 'chart.js';
import * as Misskey from 'misskey-js';
import { misskeyApiGet } from '@/scripts/misskey-api.js';
import { defaultStore } from '@/store.js';
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
import { chartVLine } from '@/scripts/chart-vline.js';
import { alpha } from '@/scripts/color.js';
import date from '@/filters/date.js';
import bytes from '@/filters/bytes.js';
import { initChart } from '@/scripts/init-chart.js';
import { chartLegend } from '@/scripts/chart-legend.js';
import MkChartLegend from '@/components/MkChartLegend.vue';
initChart();
type ChartSrc =
<script lang="ts">
export type ChartSrc =
| 'federation'
| 'ap-request'
| 'users'
@ -62,7 +41,30 @@ type ChartSrc =
| 'per-user-pv'
| 'per-user-following'
| 'per-user-followers'
| 'per-user-drive'
| 'per-user-drive';
</script>
<script lang="ts" setup>
/* eslint-disable id-denylist --
Chart.js has a `data` attribute in most chart definitions, which triggers the
id-denylist violation when setting it. This is causing about 60+ lint issues.
As this is part of Chart.js's API it makes sense to disable the check here.
*/
import { onMounted, ref, shallowRef, watch } from 'vue';
import { Chart } from 'chart.js';
import * as Misskey from 'misskey-js';
import { misskeyApiGet } from '@/scripts/misskey-api.js';
import { defaultStore } from '@/store.js';
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
import { chartVLine } from '@/scripts/chart-vline.js';
import { alpha } from '@/scripts/color.js';
import date from '@/filters/date.js';
import bytes from '@/filters/bytes.js';
import { initChart } from '@/scripts/init-chart.js';
import { chartLegend } from '@/scripts/chart-legend.js';
import MkChartLegend from '@/components/MkChartLegend.vue';
initChart();
const props = withDefaults(defineProps<{
src: ChartSrc;

View file

@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, onMounted, onUnmounted, ref } from 'vue';
import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
import * as os from '@/os.js';
import { useInterval } from '@/scripts/use-interval.js';
import { useInterval } from '@@/js/use-interval.js';
import * as game from '@/scripts/clicker-game.js';
import number from '@/filters/number.js';
import { claimAchievement } from '@/scripts/achievements.js';

View file

@ -31,6 +31,7 @@ export const Default = {
},
args: {
clip: clip(),
noUserInfo: false,
},
parameters: {
layout: 'fullscreen',

View file

@ -12,10 +12,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="clip.lastClippedAt">{{ i18n.ts.updatedAt }}: <MkTime :time="clip.lastClippedAt" mode="detail"/></div>
<div v-if="clip.notesCount != null">{{ i18n.ts.notesCount }}: {{ number(clip.notesCount) }} / {{ $i?.policies.noteEachClipsLimit }} ({{ i18n.tsx.remainingN({ n: remaining }) }})</div>
</div>
<div :class="$style.divider"></div>
<div>
<MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/>
</div>
<template v-if="!props.noUserInfo">
<div :class="$style.divider"></div>
<div>
<MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/>
</div>
</template>
</div>
</MkA>
</template>
@ -27,9 +29,12 @@ import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
import number from '@/filters/number.js';
const props = defineProps<{
const props = withDefaults(defineProps<{
clip: Misskey.entities.Clip;
}>();
noUserInfo?: boolean;
}>(), {
noUserInfo: false,
});
const remaining = computed(() => {
return ($i?.policies && props.clip.notesCount != null) ? ($i.policies.noteEachClipsLimit - props.clip.notesCount) : i18n.ts.unknown;
@ -40,6 +45,14 @@ const remaining = computed(() => {
.link {
display: block;
&:focus-visible {
outline: none;
.root {
box-shadow: inset 0 0 0 2px var(--focus);
}
}
&:hover {
text-decoration: none;
color: var(--accent);

View file

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.codeBlockRoot">
<button :class="$style.codeBlockCopyButton" class="_button" @click="copy">
<button v-if="copyButton" :class="$style.codeBlockCopyButton" class="_button" @click="copy">
<i class="ti ti-copy"></i>
</button>
<Suspense>
@ -30,14 +30,19 @@ import * as os from '@/os.js';
import MkLoading from '@/components/global/MkLoading.vue';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
const props = defineProps<{
const props = withDefaults(defineProps<{
code: string;
forceShow?: boolean;
copyButton?: boolean;
lang?: string;
}>();
}>(), {
copyButton: true,
forceShow: false,
});
const show = ref(!defaultStore.state.dataSaver.code);
const show = ref(props.forceShow === true ? true : !defaultStore.state.dataSaver.code);
const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue'));

View file

@ -216,7 +216,7 @@ onUnmounted(() => {
left: 0;
width: 100%;
height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15));
background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
> .fadeLabel {
display: inline-block;

View file

@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
>
<div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
<MkMenu :items="items" :align="'left'" @close="$emit('closed')"/>
<MkMenu :items="items" :align="'left'" @close="emit('closed')"/>
</div>
</Transition>
</template>
@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onMounted, onBeforeUnmount, shallowRef, ref } from 'vue';
import MkMenu from './MkMenu.vue';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
import contains from '@/scripts/contains.js';
import { defaultStore } from '@/store.js';
import * as os from '@/os.js';

View file

@ -39,7 +39,7 @@ import MkModalWindow from '@/components/MkModalWindow.vue';
import * as os from '@/os.js';
import { $i } from '@/account.js';
import { defaultStore } from '@/store.js';
import { apiUrl } from '@/config.js';
import { apiUrl } from '@@/js/config.js';
import { i18n } from '@/i18n.js';
import { getProxiedImageUrl } from '@/scripts/media-proxy.js';

View file

@ -45,11 +45,11 @@ function toggle() {
.label {
margin-left: 4px;
&:before {
&::before {
content: '(';
}
&:after {
&::after {
content: ')';
}
}

View file

@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkDateSeparatedList from './MkDateSeparatedList.vue';
void MkDateSeparatedList;

View file

@ -43,9 +43,9 @@ export default defineComponent({
setup(props, { slots, expose }) {
const $style = useCssModule(); // 使
function getDateText(time: string) {
const date = new Date(time).getDate();
const month = new Date(time).getMonth() + 1;
function getDateText(dateInstance: Date) {
const date = dateInstance.getDate();
const month = dateInstance.getMonth() + 1;
return i18n.tsx.monthAndDay({
month: month.toString(),
day: date.toString(),
@ -62,9 +62,16 @@ export default defineComponent({
})[0];
if (el.key == null && item.id) el.key = item.id;
const date = new Date(item.createdAt);
const nextDate = props.items[i + 1] ? new Date(props.items[i + 1].createdAt) : null;
if (
i !== props.items.length - 1 &&
new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate()
nextDate != null && (
date.getFullYear() !== nextDate.getFullYear() ||
date.getMonth() !== nextDate.getMonth() ||
date.getDate() !== nextDate.getDate()
)
) {
const separator = h('div', {
class: $style['separator'],
@ -78,12 +85,12 @@ export default defineComponent({
h('i', {
class: `ti ti-chevron-up ${$style['date-1-icon']}`,
}),
getDateText(item.createdAt),
getDateText(date),
]),
h('span', {
class: $style['date-2'],
}, [
getDateText(props.items[i + 1].createdAt),
getDateText(nextDate),
h('i', {
class: `ti ti-chevron-down ${$style['date-2-icon']}`,
}),

View file

@ -0,0 +1,159 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { action } from '@storybook/addon-actions';
import { expect, userEvent, waitFor, within } from '@storybook/test';
import { StoryObj } from '@storybook/vue3';
import { i18n } from '@/i18n.js';
import MkDialog from './MkDialog.vue';
const Base = {
render(args) {
return {
components: {
MkDialog,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
done: action('done'),
closed: action('closed'),
};
},
},
template: '<MkDialog v-bind="props" v-on="events" />',
};
},
args: {
text: 'Hello, world!',
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkDialog>;
export const Success = {
...Base,
args: {
...Base.args,
type: 'success',
},
} satisfies StoryObj<typeof MkDialog>;
export const Error = {
...Base,
args: {
...Base.args,
type: 'error',
},
} satisfies StoryObj<typeof MkDialog>;
export const Warning = {
...Base,
args: {
...Base.args,
type: 'warning',
},
} satisfies StoryObj<typeof MkDialog>;
export const Info = {
...Base,
args: {
...Base.args,
type: 'info',
},
} satisfies StoryObj<typeof MkDialog>;
export const Question = {
...Base,
args: {
...Base.args,
type: 'question',
},
} satisfies StoryObj<typeof MkDialog>;
export const Waiting = {
...Base,
args: {
...Base.args,
type: 'waiting',
},
} satisfies StoryObj<typeof MkDialog>;
export const DialogWithActions = {
...Question,
args: {
...Question.args,
text: i18n.ts.areYouSure,
actions: [
{
text: i18n.ts.yes,
primary: true,
callback() {
action('YES')();
},
},
{
text: i18n.ts.no,
callback() {
action('NO')();
},
},
],
},
} satisfies StoryObj<typeof MkDialog>;
export const DialogWithDangerActions = {
...Warning,
args: {
...Warning.args,
text: i18n.ts.resetAreYouSure,
actions: [
{
text: i18n.ts.yes,
danger: true,
primary: true,
callback() {
action('YES')();
},
},
{
text: i18n.ts.no,
callback() {
action('NO')();
},
},
],
},
} satisfies StoryObj<typeof MkDialog>;
export const DialogWithInput = {
...Question,
args: {
...Question.args,
title: 'Hello, world!',
text: undefined,
input: {
placeholder: i18n.ts.inputMessageHere,
type: 'text',
default: null,
minLength: 2,
maxLength: 3,
},
},
async play({ canvasElement }) {
const canvas = within(canvasElement);
await expect(canvasElement).toHaveTextContent(i18n.tsx._dialog.charactersBelow({ current: 0, min: 2 }));
const okButton = canvas.getByRole('button', { name: i18n.ts.ok });
await expect(okButton).toBeDisabled();
const input = canvas.getByRole<HTMLInputElement>('combobox');
await waitFor(() => userEvent.hover(input));
await waitFor(() => userEvent.click(input));
await waitFor(() => userEvent.type(input, 'M'));
await expect(canvasElement).toHaveTextContent(i18n.tsx._dialog.charactersBelow({ current: 1, min: 2 }));
await waitFor(() => userEvent.type(input, 'i'));
await expect(okButton).toBeEnabled();
},
} satisfies StoryObj<typeof MkDialog>;

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')">
<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')" @esc="cancel()">
<div :class="$style.root">
<div v-if="icon" :class="$style.icon">
<i :class="icon"></i>
@ -36,7 +36,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
<MkSelect v-if="select" v-model="selectedValue" autofocus>
<template v-if="select.items">
<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
<template v-for="item in select.items">
<optgroup v-if="'sectionTitle' in item" :label="item.sectionTitle">
<option v-for="subItem in item.items" :value="subItem.value">{{ subItem.text }}</option>
</optgroup>
<option v-else :value="item.value">{{ item.text }}</option>
</template>
</template>
</MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
@ -51,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, ref, shallowRef, computed } from 'vue';
import { ref, shallowRef, computed } from 'vue';
import MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
@ -67,11 +72,16 @@ type Input = {
maxLength?: number;
};
type SelectItem = {
value: any;
text: string;
};
type Select = {
items: {
value: any;
text: string;
}[];
items: (SelectItem | {
sectionTitle: string;
items: SelectItem[];
})[];
default: string | null;
};
@ -156,10 +166,6 @@ function onBgClick() {
if (props.cancelableByBgClick) cancel();
}
*/
function onKeydown(evt: KeyboardEvent) {
if (evt.key === 'Escape') cancel();
}
function onInputKeydown(evt: KeyboardEvent) {
if (evt.key === 'Enter' && okButtonDisabledReason.value === null) {
evt.preventDefault();
@ -167,14 +173,6 @@ function onInputKeydown(evt: KeyboardEvent) {
ok();
}
}
onMounted(() => {
document.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeydown);
});
</script>
<style lang="scss" module>

View file

@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkDivider from './MkDivider.vue';
void MkDivider;

View file

@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { action } from '@storybook/addon-actions';
import { StoryObj } from '@storybook/vue3';
import { onBeforeUnmount } from 'vue';
import MkDonation from './MkDonation.vue';
import { instance } from '@/instance.js';
export const Default = {
render(args) {
return {
components: {
MkDonation,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
closed: action('closed'),
};
},
},
template: '<MkDonation v-bind="props" v-on="events" />',
};
},
args: {
// @ts-expect-error name is used for mocking instance
name: 'Misskey Hub',
},
decorators: [
(_, { args }) => ({
setup() {
// @ts-expect-error name is used for mocking instance
instance.name = args.name;
onBeforeUnmount(() => instance.name = null);
},
template: '<story/>',
}),
],
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkDonation>;

View file

@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import MkButton from '@/components/MkButton.vue';
import MkLink from '@/components/MkLink.vue';
import { host } from '@/config.js';
import { host } from '@@/js/config.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { miLocalStorage } from '@/local-storage.js';

View file

@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { action } from '@storybook/addon-actions';
import { StoryObj } from '@storybook/vue3';
import MkDrive_file from './MkDrive.file.vue';
import { file } from '../../.storybook/fakes.js';
export const Default = {
render(args) {
return {
components: {
MkDrive_file,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
chosen: action('chosen'),
dragstart: action('dragstart'),
dragend: action('dragend'),
};
},
},
template: '<MkDrive_file v-bind="props" v-on="events" />',
};
},
args: {
file: file(),
},
parameters: {
chromatic: {
// NOTE: ロードが終わるまで待つ
delay: 3000,
},
layout: 'centered',
},
} satisfies StoryObj<typeof MkDrive_file>;

View file

@ -115,14 +115,14 @@ function onDragend() {
background: rgba(#000, 0.05);
> .label {
&:before,
&:after {
&::before,
&::after {
background: #0b65a5;
}
&.red {
&:before,
&:after {
&::before,
&::after {
background: #c12113;
}
}
@ -133,14 +133,14 @@ function onDragend() {
background: rgba(#000, 0.1);
> .label {
&:before,
&:after {
&::before,
&::after {
background: #0b588c;
}
&.red {
&:before,
&:after {
&::before,
&::after {
background: #ce2212;
}
}
@ -159,8 +159,8 @@ function onDragend() {
}
> .label {
&:before,
&:after {
&::before,
&::after {
display: none;
}
}
@ -181,8 +181,8 @@ function onDragend() {
left: 0;
pointer-events: none;
&:before,
&:after {
&::before,
&::after {
content: "";
display: block;
position: absolute;
@ -190,14 +190,14 @@ function onDragend() {
background: #0c7ac9;
}
&:before {
&::before {
top: 0;
left: 57px;
width: 28px;
height: 8px;
}
&:after {
&::after {
top: 57px;
left: 0;
width: 8px;
@ -205,8 +205,8 @@ function onDragend() {
}
&.red {
&:before,
&:after {
&::before,
&::after {
background: #c12113;
}
}

View file

@ -0,0 +1,70 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { action } from '@storybook/addon-actions';
import { StoryObj } from '@storybook/vue3';
import { http, HttpResponse } from 'msw';
import * as Misskey from 'misskey-js';
import MkDrive_folder from './MkDrive.folder.vue';
import { folder } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
export const Default = {
render(args) {
return {
components: {
MkDrive_folder,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
chosen: action('chosen'),
move: action('move'),
upload: action('upload'),
removeFile: action('removeFile'),
removeFolder: action('removeFolder'),
dragstart: action('dragstart'),
dragend: action('dragend'),
};
},
},
template: '<MkDrive_folder v-bind="props" v-on="events" />',
};
},
args: {
folder: folder(),
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.post('/api/drive/folders/delete', async ({ request }) => {
action('POST /api/drive/folders/delete')(await request.json());
return HttpResponse.json(undefined, { status: 204 });
}),
http.post('/api/drive/folders/update', async ({ request }) => {
const req = await request.json() as Misskey.entities.DriveFoldersUpdateRequest;
action('POST /api/drive/folders/update')(req);
return HttpResponse.json({
...folder(),
id: req.folderId,
name: req.name ?? folder().name,
parentId: req.parentId ?? folder().parentId,
});
}),
],
},
},
} satisfies StoryObj<typeof MkDrive_folder>;

View file

@ -27,7 +27,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<p v-if="defaultStore.state.uploadFolder == folder.id" :class="$style.upload">
{{ i18n.ts.uploadFolder }}
</p>
<button v-if="selectMode" class="_button" :class="[$style.checkbox, { [$style.checked]: isSelected }]" @click.prevent.stop="checkboxClicked"></button>
<button v-if="selectMode" class="_button" :class="$style.checkboxWrapper" @click.prevent.stop="checkboxClicked">
<div :class="[$style.checkbox, { [$style.checked]: isSelected }]"></div>
</button>
</div>
</template>
@ -39,8 +41,8 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';
import { claimAchievement } from '@/scripts/achievements.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { MenuItem } from '@/types/menu.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import type { MenuItem } from '@/types/menu.js';
const props = withDefaults(defineProps<{
folder: Misskey.entities.DriveFolder;
@ -53,6 +55,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(ev: 'chosen', v: Misskey.entities.DriveFolder): void;
(ev: 'unchose', 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;
@ -68,7 +71,11 @@ const isDragging = ref(false);
const title = computed(() => props.folder.name);
function checkboxClicked() {
emit('chosen', props.folder);
if (props.isSelected) {
emit('unchose', props.folder);
} else {
emit('chosen', props.folder);
}
}
function onClick() {
@ -222,6 +229,17 @@ function rename() {
});
}
function move() {
os.selectDriveFolder(false).then(folder => {
if (folder[0] && folder[0].id === props.folder.id) return;
misskeyApi('drive/folders/update', {
folderId: props.folder.id,
parentId: folder[0] ? folder[0].id : null,
});
});
}
function deleteFolder() {
misskeyApi('drive/folders/delete', {
folderId: props.folder.id,
@ -267,6 +285,10 @@ function onContextmenu(ev: MouseEvent) {
text: i18n.ts.rename,
icon: 'ti ti-forms',
action: rename,
}, {
text: i18n.ts.move,
icon: 'ti ti ti-folder-symlink',
action: move,
}, { type: 'divider' }, {
text: i18n.ts.delete,
icon: 'ti ti-trash',
@ -296,7 +318,7 @@ function onContextmenu(ev: MouseEvent) {
cursor: pointer;
&.draghover {
&:after {
&::after {
content: "";
pointer-events: none;
position: absolute;
@ -310,17 +332,43 @@ function onContextmenu(ev: MouseEvent) {
}
}
.checkbox {
.checkboxWrapper {
position: absolute;
bottom: 8px;
right: 8px;
width: 16px;
height: 16px;
background: #fff;
border: solid 1px #000;
border-radius: 50%;
bottom: 2px;
right: 2px;
padding: 8px;
box-sizing: border-box;
&.checked {
background: var(--accent);
> .checkbox {
position: relative;
width: 18px;
height: 18px;
background: #fff;
border: solid 2px var(--divider);
border-radius: 4px;
box-sizing: border-box;
&.checked {
border-color: var(--accent);
background: var(--accent);
&::after {
content: "\ea5e";
font-family: 'tabler-icons';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #fff;
font-size: 12px;
line-height: 22px;
}
}
}
&:hover {
background: var(--accentedBg);
}
}

View file

@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkDrive_navFolder from './MkDrive.navFolder.vue';
void MkDrive_navFolder;

View file

@ -0,0 +1,82 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { action } from '@storybook/addon-actions';
import { StoryObj } from '@storybook/vue3';
import { http, HttpResponse } from 'msw';
import * as Misskey from 'misskey-js';
import MkDrive from './MkDrive.vue';
import { file, folder } from '../../.storybook/fakes.js';
import { commonHandlers } from '../../.storybook/mocks.js';
export const Default = {
render(args) {
return {
components: {
MkDrive,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
selected: action('selected'),
'change-selection': action('change-selection'),
'move-root': action('move-root'),
cd: action('cd'),
'open-folder': action('open-folder'),
};
},
},
template: '<MkDrive v-bind="props" v-on="events" />',
};
},
parameters: {
chromatic: {
// NOTE: ロードが終わるまで待つ
delay: 3000,
},
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
http.post('/api/drive/files', async ({ request }) => {
action('POST /api/drive/files')(await request.json());
return HttpResponse.json([file()]);
}),
http.post('/api/drive/folders', async ({ request }) => {
action('POST /api/drive/folders')(await request.json());
return HttpResponse.json([folder(crypto.randomUUID())]);
}),
http.post('/api/drive/folders/create', async ({ request }) => {
const req = await request.json() as Misskey.entities.DriveFoldersCreateRequest;
action('POST /api/drive/folders/create')(req);
return HttpResponse.json(folder(crypto.randomUUID(), req.name, req.parentId));
}),
http.post('/api/drive/folders/delete', async ({ request }) => {
action('POST /api/drive/folders/delete')(await request.json());
return HttpResponse.json(undefined, { status: 204 });
}),
http.post('/api/drive/folders/update', async ({ request }) => {
const req = await request.json() as Misskey.entities.DriveFoldersUpdateRequest;
action('POST /api/drive/folders/update')(req);
return HttpResponse.json({
...folder(),
id: req.folderId,
name: req.name ?? folder().name,
parentId: req.parentId ?? folder().parentId,
});
}),
]
},
},
} satisfies StoryObj<typeof MkDrive>;

View file

@ -52,6 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:selectMode="select === 'folder'"
:isSelected="selectedFolders.some(x => x.id === f.id)"
@chosen="chooseFolder"
@unchose="unchoseFolder"
@move="move"
@upload="upload"
@removeFile="removeFile"
@ -428,6 +429,11 @@ function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) {
}
}
function unchoseFolder(folderToUnchose: Misskey.entities.DriveFolder) {
selectedFolders.value = selectedFolders.value.filter(f => f.id !== folderToUnchose.id);
emit('change-selection', selectedFolders.value);
}
function move(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder['id' | 'parentId']) {
if (!target) {
goRoot();
@ -614,7 +620,9 @@ function fetchMoreFiles() {
}
function getMenu() {
const menu: MenuItem[] = [{
const menu: MenuItem[] = [];
menu.push({
type: 'switch',
text: i18n.ts.keepOriginalUploading,
ref: keepOriginal,
@ -632,19 +640,25 @@ function getMenu() {
}, { type: 'divider' }, {
text: folder.value ? folder.value.name : i18n.ts.drive,
type: 'label',
}, folder.value ? {
text: i18n.ts.renameFolder,
icon: 'ti ti-forms',
action: () => { if (folder.value) renameFolder(folder.value); },
} : undefined, folder.value ? {
text: i18n.ts.deleteFolder,
icon: 'ti ti-trash',
action: () => { deleteFolder(folder.value as Misskey.entities.DriveFolder); },
} : undefined, {
});
if (folder.value) {
menu.push({
text: i18n.ts.renameFolder,
icon: 'ti ti-forms',
action: () => { if (folder.value) renameFolder(folder.value); },
}, {
text: i18n.ts.deleteFolder,
icon: 'ti ti-trash',
action: () => { deleteFolder(folder.value as Misskey.entities.DriveFolder); },
});
}
menu.push({
text: i18n.ts.createFolder,
icon: 'ti ti-folder-plus',
action: () => { createFolder(); },
}];
});
return menu;
}

View file

@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { StoryObj } from '@storybook/vue3';
import MkDriveFileThumbnail from './MkDriveFileThumbnail.vue';
import { file } from '../../.storybook/fakes.js';
export const Default = {
render(args) {
return {
components: {
MkDriveFileThumbnail,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkDriveFileThumbnail v-bind="props" />',
};
},
args: {
file: file(),
fit: 'contain',
},
parameters: {
chromatic: {
// NOTE: ロードが終わるまで待つ
delay: 3000,
},
layout: 'centered',
},
} satisfies StoryObj<typeof MkDriveFileThumbnail>;

View file

@ -4,7 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div ref="thumbnail" :class="$style.root">
<div
ref="thumbnail"
:class="[
$style.root,
{ [$style.sensitiveHighlight]: highlightWhenSensitive && file.isSensitive },
]"
>
<ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/>
<i v-else-if="is === 'image'" class="ti ti-photo" :class="$style.icon"></i>
<i v-else-if="is === 'video'" class="ti ti-video" :class="$style.icon"></i>
@ -26,7 +32,8 @@ import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
const props = defineProps<{
file: Misskey.entities.DriveFile;
fit: string;
fit: 'cover' | 'contain';
highlightWhenSensitive?: boolean;
}>();
const is = computed(() => {
@ -67,6 +74,18 @@ const isThumbnailAvailable = computed(() => {
overflow: clip;
}
.sensitiveHighlight::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
border-radius: inherit;
box-shadow: inset 0 0 0 4px var(--warn);
}
.iconSub {
position: absolute;
width: 30%;

View file

@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkDriveSelectDialog from './MkDriveSelectDialog.vue';
void MkDriveSelectDialog;

View file

@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkDriveWindow from './MkDriveWindow.vue';
void MkDriveWindow;

View file

@ -0,0 +1,414 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialogEl"
:width="1000"
:height="600"
:scroll="false"
:withOkButton="false"
@close="cancel()"
@closed="$emit('closed')"
>
<template #header><i class="ti ti-code"></i> {{ i18n.ts._embedCodeGen.title }}</template>
<div :class="$style.embedCodeGenRoot">
<Transition
mode="out-in"
:enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom"
:leaveToClass="$style.transition_x_leaveTo"
>
<div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot">
<div
:class="$style.embedCodeGenPreviewRoot"
>
<MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/>
<div :class="$style.embedCodeGenPreviewWrapper">
<div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
<div ref="resizerRootEl" :class="$style.embedCodeGenPreviewResizerRoot" inert>
<div
:class="$style.embedCodeGenPreviewResizer"
:style="{ transform: iframeStyle }"
>
<iframe
ref="iframeEl"
:src="embedPreviewUrl"
:class="$style.embedCodeGenPreviewIframe"
:style="{ height: `${iframeHeight}px` }"
@load="iframeOnLoad"
></iframe>
</div>
</div>
</div>
</div>
<div :class="$style.embedCodeGenSettings" class="_gaps">
<MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0">
<template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template>
<template #suffix>px</template>
<template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template>
</MkInput>
<MkSelect v-model="colorMode">
<template #label>{{ i18n.ts.theme }}</template>
<option value="auto">{{ i18n.ts.syncDeviceDarkMode }}</option>
<option value="light">{{ i18n.ts.light }}</option>
<option value="dark">{{ i18n.ts.dark }}</option>
</MkSelect>
<MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch>
<MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch>
<MkSwitch v-model="border">{{ i18n.ts._embedCodeGen.border }}</MkSwitch>
<MkInfo v-if="isEmbedWithScrollbar && (!maxHeight || maxHeight <= 0)" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo>
<MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo>
<div class="_buttons">
<MkButton :disabled="iframeLoading" @click="applyToPreview">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton>
<MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
</div>
<div v-else-if="phase === 'result'" key="result" :class="$style.embedCodeGenResultRoot">
<div :class="$style.embedCodeGenResultWrapper" class="_gaps">
<div class="_gaps_s">
<div :class="$style.embedCodeGenResultHeadingIcon"><i class="ti ti-check"></i></div>
<div :class="$style.embedCodeGenResultHeading">{{ i18n.ts._embedCodeGen.codeGenerated }}</div>
<div :class="$style.embedCodeGenResultDescription">{{ i18n.ts._embedCodeGen.codeGeneratedDescription }}</div>
</div>
<div class="_gaps_s">
<MkCode :code="result" lang="html" :forceShow="true" :copyButton="false" :class="$style.embedCodeGenResultCode"/>
<MkButton :class="$style.embedCodeGenResultButtons" rounded primary @click="doCopy"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
</div>
<MkButton :class="$style.embedCodeGenResultButtons" rounded transparent @click="close">{{ i18n.ts.close }}</MkButton>
</div>
</div>
</Transition>
</div>
</MkModalWindow>
</template>
<script setup lang="ts">
import { shallowRef, ref, computed, nextTick, onMounted, onDeactivated, onUnmounted } from 'vue';
import { url } from '@@/js/config.js';
import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkButton from '@/components/MkButton.vue';
import MkCode from '@/components/MkCode.vue';
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { normalizeEmbedParams, getEmbedCode } from '@/scripts/get-embed-code.js';
const emit = defineEmits<{
(ev: 'ok'): void;
(ev: 'cancel'): void;
(ev: 'closed'): void;
}>();
const props = defineProps<{
entity: EmbeddableEntity;
id: string;
params?: EmbedParams;
}>();
//#region Modal
const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
function cancel() {
emit('cancel');
dialogEl.value?.close();
}
function close() {
dialogEl.value?.close();
}
const phase = ref<'input' | 'result'>('input');
//#endregion
//#region URL
// URLparams
const paramsForUrl = computed<EmbedParams>(() => ({
header: header.value,
maxHeight: typeof maxHeight.value === 'number' ? Math.max(0, maxHeight.value) : undefined,
colorMode: colorMode.value === 'auto' ? undefined : colorMode.value,
rounded: rounded.value,
border: border.value,
}));
// paramsref
const paramsForPreview = ref<EmbedParams>(props.params ?? {});
const embedPreviewUrl = computed(() => {
const paramClass = new URLSearchParams(normalizeEmbedParams(paramsForPreview.value));
if (paramClass.has('maxHeight')) {
const maxHeight = parseInt(paramClass.get('maxHeight')!);
paramClass.set('maxHeight', maxHeight === 0 ? '500' : Math.min(maxHeight, 700).toString()); // 700px
}
return `${url}/embed/${props.entity}/${props.id}${paramClass.toString() ? '?' + paramClass.toString() : ''}`;
});
const isEmbedWithScrollbar = computed(() => embedRouteWithScrollbar.includes(props.entity));
const header = ref(props.params?.header ?? true);
const maxHeight = ref(props.params?.maxHeight !== 0 ? props.params?.maxHeight ?? undefined : 500);
const colorMode = ref<'light' | 'dark' | 'auto'>(props.params?.colorMode ?? 'auto');
const rounded = ref(props.params?.rounded ?? true);
const border = ref(props.params?.border ?? true);
function applyToPreview() {
const currentPreviewUrl = embedPreviewUrl.value;
paramsForPreview.value = {
header: header.value,
maxHeight: typeof maxHeight.value === 'number' ? Math.max(0, maxHeight.value) : undefined,
colorMode: colorMode.value === 'auto' ? undefined : colorMode.value,
rounded: rounded.value,
border: border.value,
};
nextTick(() => {
if (currentPreviewUrl === embedPreviewUrl.value) {
// URL
iframeEl.value?.contentWindow?.location.reload();
}
});
}
const result = ref('');
function generate() {
result.value = getEmbedCode(`/embed/${props.entity}/${props.id}`, paramsForUrl.value);
phase.value = 'result';
}
function doCopy() {
copyToClipboard(result.value);
os.success();
}
//#endregion
//#region
const resizerRootEl = shallowRef<HTMLDivElement>();
const iframeLoading = ref(true);
const iframeEl = shallowRef<HTMLIFrameElement>();
const iframeHeight = ref(0);
const iframeScale = ref(1);
const iframeStyle = computed(() => {
return `translate(-50%, -50%) scale(${iframeScale.value})`;
});
const resizeObserver = new ResizeObserver(() => {
calcScale();
});
function iframeOnLoad() {
iframeEl.value?.contentWindow?.addEventListener('beforeunload', () => {
iframeLoading.value = true;
nextTick(() => {
iframeHeight.value = 0;
iframeScale.value = 1;
});
});
}
function windowEventHandler(event: MessageEvent) {
if (event.source !== iframeEl.value?.contentWindow) {
return;
}
if (event.data.type === 'misskey:embed:ready') {
iframeEl.value!.contentWindow?.postMessage({
type: 'misskey:embedParent:registerIframeId',
payload: {
iframeId: 'embedCodeGen', // embed iframe
},
});
}
if (event.data.type === 'misskey:embed:changeHeight') {
iframeHeight.value = event.data.payload.height;
nextTick(() => {
calcScale();
iframeLoading.value = false; //
});
}
}
function calcScale() {
if (!resizerRootEl.value) return;
const previewWidth = resizerRootEl.value.clientWidth - 40; // 20px
const previewHeight = resizerRootEl.value.clientHeight - 40; // 20px
const iframeWidth = 500;
const scale = Math.min(previewWidth / iframeWidth, previewHeight / iframeHeight.value, 1); // 1
iframeScale.value = scale;
}
onMounted(() => {
window.addEventListener('message', windowEventHandler);
if (!resizerRootEl.value) return;
resizeObserver.observe(resizerRootEl.value);
});
function reset() {
window.removeEventListener('message', windowEventHandler);
resizeObserver.disconnect();
//
iframeHeight.value = 0;
iframeScale.value = 1;
iframeLoading.value = true;
result.value = '';
phase.value = 'input';
}
onDeactivated(() => {
reset();
});
onUnmounted(() => {
reset();
});
//#endregion
</script>
<style module>
.transition_x_enterActive,
.transition_x_leaveActive {
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
}
.transition_x_enterFrom {
opacity: 0;
transform: translateX(50px);
}
.transition_x_leaveTo {
opacity: 0;
transform: translateX(-50px);
}
.embedCodeGenRoot {
container-type: inline-size;
height: 100%;
}
.embedCodeGenInputRoot {
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.embedCodeGenPreviewRoot {
position: relative;
background-color: var(--bg);
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--panel) 6px, var(--panel) 12px);
cursor: not-allowed;
}
.embedCodeGenPreviewWrapper {
display: flex;
flex-direction: column;
height: 100%;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.embedCodeGenPreviewTitle {
position: absolute;
z-index: 100;
top: 8px;
left: 8px;
padding: 6px 10px;
border-radius: 6px;
font-size: 85%;
}
.embedCodeGenPreviewSpinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.embedCodeGenPreviewResizerRoot {
position: relative;
flex: 1 0;
}
.embedCodeGenPreviewResizer {
position: absolute;
top: 50%;
left: 50%;
}
.embedCodeGenPreviewIframe {
display: block;
border: none;
width: 500px;
color-scheme: light dark;
}
.embedCodeGenSettings {
padding: 24px;
overflow-y: scroll;
}
.embedCodeGenResultRoot {
box-sizing: border-box;
padding: 24px;
height: 100%;
max-width: 700px;
margin: 0 auto;
display: flex;
align-items: center;
}
.embedCodeGenResultHeading {
text-align: center;
font-size: 1.2em;
}
.embedCodeGenResultHeadingIcon {
margin: 0 auto;
background-color: var(--accentedBg);
color: var(--accent);
text-align: center;
height: 64px;
width: 64px;
font-size: 24px;
line-height: 64px;
border-radius: 50%;
}
.embedCodeGenResultDescription {
text-align: center;
white-space: pre-wrap;
}
.embedCodeGenResultWrapper,
.embedCodeGenResultCode {
width: 100%;
}
.embedCodeGenResultButtons {
margin: 0 auto;
}
@container (max-width: 800px) {
.embedCodeGenInputRoot {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>

View file

@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkEmojiPicker_section from './MkEmojiPicker.section.vue';
void MkEmojiPicker_section;

View file

@ -62,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, computed, Ref } from 'vue';
import { CustomEmojiFolderTree, getEmojiName } from '@/scripts/emojilist.js';
import { CustomEmojiFolderTree, getEmojiName } from '@@/js/emojilist.js';
import { i18n } from '@/i18n.js';
import { customEmojis } from '@/custom-emojis.js';
import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue';

View file

@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { action } from '@storybook/addon-actions';
import { expect, userEvent, waitFor, within } from '@storybook/test';
import { StoryObj } from '@storybook/vue3';
import { i18n } from '@/i18n.js';
import MkEmojiPicker from './MkEmojiPicker.vue';
export const Default = {
render(args) {
return {
components: {
MkEmojiPicker,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
events() {
return {
chosen: action('chosen'),
};
},
},
template: '<MkEmojiPicker v-bind="props" v-on="events" />',
};
},
async play({ canvasElement }) {
const canvas = within(canvasElement);
const faceSection = canvas.getByText(/face/i);
await waitFor(() => userEvent.click(faceSection));
const grinning = canvasElement.querySelector('[data-emoji="😀"]');
await expect(grinning).toBeInTheDocument();
if (grinning == null) throw new Error(); // NOTE: not called
await waitFor(() => userEvent.click(grinning));
const recentUsedSection = canvas.getByText(new RegExp(i18n.ts.recentUsed)).parentElement;
await expect(recentUsedSection).toBeInTheDocument();
if (recentUsedSection == null) throw new Error(); // NOTE: not called
await expect(within(recentUsedSection).getByAltText('😀')).toBeInTheDocument();
await expect(within(recentUsedSection).queryByAltText('😬')).toEqual(null);
},
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkEmojiPicker>;

View file

@ -5,7 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer, asWindow }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }">
<input ref="searchEl" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" autocapitalize="off" @input="input()" @paste.stop="paste" @keydown.stop.prevent.enter="onEnter">
<input
ref="searchEl"
:value="q"
class="search"
data-prevent-emoji-insert
:class="{ filled: q != null && q != '' }"
:placeholder="i18n.ts.search"
type="search"
autocapitalize="off"
@input="input()"
@paste.stop="paste"
@keydown="onKeydown"
>
<!-- FirefoxのTabフォーカスが想定外の挙動となるためtabindex="-1"を追加 https://github.com/misskey-dev/misskey/issues/10744 -->
<div ref="emojisEl" class="emojis" tabindex="-1">
<section class="result">
@ -105,7 +117,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, shallowRef, computed, watch, onMounted } from 'vue';
import * as Misskey from 'misskey-js';
import XSection from '@/components/MkEmojiPicker.section.vue';
import {
emojilist,
emojiCharByCategory,
@ -114,7 +125,8 @@ import {
getEmojiName,
CustomEmojiFolderTree,
getUnicodeEmoji,
} from '@/scripts/emojilist.js';
} from '@@/js/emojilist.js';
import XSection from '@/components/MkEmojiPicker.section.vue';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import * as os from '@/os.js';
import { isTouchUsing } from '@/scripts/touch.js';
@ -139,6 +151,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(ev: 'chosen', v: string): void;
(ev: 'esc'): void;
}>();
const searchEl = shallowRef<HTMLInputElement>();
@ -433,9 +446,18 @@ function paste(event: ClipboardEvent): void {
}
}
function onEnter(ev: KeyboardEvent) {
function onKeydown(ev: KeyboardEvent) {
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return;
done();
if (ev.key === 'Enter') {
ev.preventDefault();
ev.stopPropagation();
done();
}
if (ev.key === 'Escape') {
ev.preventDefault();
ev.stopPropagation();
emit('esc');
}
}
function done(query?: string): boolean | void {
@ -589,6 +611,7 @@ defineExpose({
width: auto;
height: auto;
min-width: 0;
padding: 0;
&:disabled {
cursor: not-allowed;
@ -695,18 +718,13 @@ defineExpose({
> .item {
position: relative;
padding: 0;
padding: 0 3px;
width: var(--eachSize);
height: var(--eachSize);
contain: strict;
border-radius: 4px;
font-size: 24px;
&:focus-visible {
outline: solid 2px var(--focus);
z-index: 1;
}
&:hover {
background: rgba(0, 0, 0, 0.05);
}

View file

@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkEmojiPickerDialog from './MkEmojiPickerDialog.vue';
void MkEmojiPickerDialog;

View file

@ -8,11 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="modal"
v-slot="{ type, maxHeight }"
:zPriority="'middle'"
:preferType="defaultStore.state.emojiPickerUseDrawerForMobile === false ? 'popup' : 'auto'"
:preferType="defaultStore.state.emojiPickerStyle"
:hasInteractionWithOtherFocusTrappedEls="true"
:transparentBg="true"
:manualShowing="manualShowing"
:src="src"
@click="modal?.close()"
@esc="modal?.close()"
@opening="opening"
@close="emit('close')"
@closed="emit('closed')"
@ -28,6 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:asDrawer="type === 'drawer'"
:max-height="maxHeight"
@chosen="chosen"
@esc="modal?.close()"
/>
</MkModal>
</template>

View file

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
class="file _button"
>
<div v-if="file.isSensitive" class="sensitive-label">{{ i18n.ts.sensitive }}</div>
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain" :highlightWhenSensitive="true"/>
<div v-if="viewMode === 'list'" class="body">
<div>
<small style="opacity: 0.7;">{{ file.name }}</small>

View file

@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { StoryObj } from '@storybook/vue3';
import MkFlashPreview from './MkFlashPreview.vue';
import { flash } from './../../.storybook/fakes.js';
export const Public = {
render(args) {
return {
components: {
MkFlashPreview,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkFlashPreview v-bind="props" />',
};
},
args: {
flash: {
...flash(),
visibility: 'public',
},
},
parameters: {
layout: 'fullscreen',
},
decorators: [
() => ({
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>',
}),
],
} satisfies StoryObj<typeof MkFlashPreview>;
export const Private = {
...Public,
args: {
flash: {
...flash(),
visibility: 'private',
},
},
} satisfies StoryObj<typeof MkFlashPreview>;

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkA :to="`/play/${flash.id}`" class="vhpxefrk _panel" tabindex="-1">
<MkA :to="`/play/${flash.id}`" class="vhpxefrk _panel" :class="[{ gray: flash.visibility === 'private' }]">
<article>
<header>
<h1 :title="flash.title">{{ flash.title }}</h1>
@ -22,11 +22,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { } from 'vue';
import * as Misskey from 'misskey-js';
import { userName } from '@/filters/user.js';
const props = defineProps<{
//flash: Misskey.entities.Flash;
flash: any;
flash: Misskey.entities.Flash;
}>();
</script>
@ -39,6 +39,10 @@ const props = defineProps<{
color: var(--accent);
}
&:focus-visible {
outline-offset: -2px;
}
> article {
padding: 16px;
@ -87,6 +91,12 @@ const props = defineProps<{
}
}
&:global(.gray) {
--c: var(--bg);
background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%);
background-size: 16px 16px;
}
@media (max-width: 700px) {
}

View file

@ -7,10 +7,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<div ref="rootEl" :class="$style.root" role="group" :aria-expanded="opened">
<MkStickyContainer>
<template #header>
<div :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle">
<button :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle">
<div :class="$style.headerIcon"><slot name="icon"></slot></div>
<div :class="$style.headerText">
<div>
<div :class="$style.headerTextMain">
<MkCondensedLine :minScale="2 / 3"><slot name="label"></slot></MkCondensedLine>
</div>
<div :class="$style.headerTextSub">
@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-if="opened" class="ti ti-chevron-up icon"></i>
<i v-else class="ti ti-chevron-down icon"></i>
</div>
</div>
</button>
</template>
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened">
@ -38,9 +38,15 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<KeepAlive>
<div v-show="opened">
<MkSpacer :marginMin="14" :marginMax="22">
<MkSpacer v-if="withSpacer" :marginMin="14" :marginMax="22">
<slot></slot>
</MkSpacer>
<div v-else>
<slot></slot>
</div>
<div v-if="$slots.footer" :class="$style.footer">
<slot name="footer"></slot>
</div>
</div>
</KeepAlive>
</Transition>
@ -56,9 +62,11 @@ import { defaultStore } from '@/store.js';
const props = withDefaults(defineProps<{
defaultOpen?: boolean;
maxHeight?: number | null;
withSpacer?: boolean;
}>(), {
defaultOpen: false,
maxHeight: null,
withSpacer: true,
});
const getBgColor = (el: HTMLElement) => {
@ -136,7 +144,7 @@ onMounted(() => {
width: 100%;
box-sizing: border-box;
padding: 9px 12px 9px 12px;
background: var(--buttonBg);
background: var(--folderHeaderBg);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
border-radius: 6px;
@ -144,12 +152,16 @@ onMounted(() => {
&:hover {
text-decoration: none;
background: var(--buttonHoverBg);
background: var(--folderHeaderHoverBg);
}
&:focus-within {
outline-offset: 2px;
}
&.active {
color: var(--accent);
background: var(--buttonHoverBg);
background: var(--folderHeaderHoverBg);
}
&.opened {
@ -190,6 +202,12 @@ onMounted(() => {
padding-right: 12px;
}
.headerTextMain,
.headerTextSub {
width: fit-content;
max-width: 100%;
}
.headerTextSub {
color: var(--fgTransparentWeak);
font-size: .85em;
@ -214,4 +232,18 @@ onMounted(() => {
background: var(--bg);
}
}
.footer {
position: sticky !important;
z-index: 1;
bottom: var(--stickyBottom, 0px);
left: 0;
padding: 12px;
background: var(--acrylicBg);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
background-size: auto auto;
background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, var(--panel) 5px, var(--panel) 10px);
border-radius: 0 0 6px 6px;
}
</style>

View file

@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="full" :class="$style.text">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/>
</template>
<template v-else-if="isFollowing">
<span v-if="full" :class="$style.text">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i>
<span v-if="full" :class="$style.text">{{ i18n.ts.youFollowing }}</span><i class="ti ti-minus"></i>
</template>
<template v-else-if="!isFollowing && user.isLocked">
<span v-if="full" :class="$style.text">{{ i18n.ts.followRequest }}</span><i class="ti ti-plus"></i>
@ -42,6 +42,8 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { pleaseLogin } from '@/scripts/please-login.js';
import { host } from '@@/js/config.js';
import { $i } from '@/account.js';
import { defaultStore } from '@/store.js';
@ -63,7 +65,7 @@ const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFro
const wait = ref(false);
const connection = useStream().useChannel('main');
if (props.user.isFollowing == null) {
if (props.user.isFollowing == null && $i) {
misskeyApi('users/show', {
userId: props.user.id,
})
@ -78,6 +80,8 @@ function onFollowChange(user: Misskey.entities.UserDetailed) {
}
async function onClick() {
pleaseLogin(undefined, { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` });
wait.value = true;
try {
@ -185,17 +189,7 @@ onBeforeUnmount(() => {
}
&:focus-visible {
&:after {
content: "";
pointer-events: none;
position: absolute;
top: -5px;
right: -5px;
bottom: -5px;
left: -5px;
border: 2px solid var(--focus);
border-radius: 32px;
}
outline-offset: 2px;
}
&:hover {

View file

@ -0,0 +1,49 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<div :class="$style.text">{{ i18n.tsx.thereAreNChanges({ n: form.modifiedCount.value }) }}</div>
<div style="margin-left: auto;" class="_buttons">
<MkButton danger rounded @click="form.discard"><i class="ti ti-x"></i> {{ i18n.ts.discard }}</MkButton>
<MkButton primary rounded @click="form.save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</div>
</div>
</template>
<script lang="ts" setup>
import { } from 'vue';
import MkButton from './MkButton.vue';
import { i18n } from '@/i18n.js';
const props = defineProps<{
form: {
modifiedCount: {
value: number;
};
discard: () => void;
save: () => void;
};
}>();
</script>
<style lang="scss" module>
.root {
display: flex;
align-items: center;
}
.text {
color: var(--warn);
font-size: 90%;
animation: modified-blink 2s infinite;
}
@keyframes modified-blink {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
</style>

View file

@ -0,0 +1,100 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div
:class="[
$style.root,
tail === 'left' ? $style.left : $style.right,
negativeMargin === true && $style.negativeMergin,
shadow === true && $style.shadow,
]"
>
<div :class="$style.bg">
<svg v-if="tail !== 'none'" :class="$style.tail" version="1.1" viewBox="0 0 14.597 14.58" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-173.71 -87.184)">
<path d="m188.19 87.657c-1.469 2.3218-3.9315 3.8312-6.667 4.0865-2.2309-1.7379-4.9781-2.6816-7.8061-2.6815h-5.1e-4v12.702h12.702v-5.1e-4c2e-5 -1.9998-0.47213-3.9713-1.378-5.754 2.0709-1.6834 3.2732-4.2102 3.273-6.8791-6e-5 -0.49375-0.0413-0.98662-0.1235-1.4735z" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round" stroke-width=".33225" style="paint-order:stroke fill markers"/>
</g>
</svg>
<div :class="$style.content">
<slot></slot>
</div>
</div>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
tail?: 'left' | 'right' | 'none';
negativeMargin?: boolean;
shadow?: boolean;
}>(), {
tail: 'right',
negativeMargin: false,
shadow: false,
});
</script>
<style module lang="scss">
.root {
--fukidashi-radius: var(--radius);
--fukidashi-bg: var(--panel);
position: relative;
display: inline-block;
min-height: calc(var(--fukidashi-radius) * 2);
padding-top: calc(var(--fukidashi-radius) * .13);
&.shadow {
filter: drop-shadow(0 4px 32px var(--shadow));
}
&.left {
padding-left: calc(var(--fukidashi-radius) * .13);
&.negativeMergin {
margin-left: calc(calc(var(--fukidashi-radius) * .13) * -1);
}
}
&.right {
padding-right: calc(var(--fukidashi-radius) * .13);
&.negativeMergin {
margin-right: calc(calc(var(--fukidashi-radius) * .13) * -1);
}
}
}
.bg {
width: 100%;
height: 100%;
background: var(--fukidashi-bg);
border-radius: var(--fukidashi-radius);
}
.content {
position: relative;
padding: 8px 12px;
}
.tail {
position: absolute;
top: 0;
display: block;
width: calc(var(--fukidashi-radius) * 1.13);
height: auto;
fill: var(--fukidashi-bg);
}
.left .tail {
left: 0;
transform: rotateY(180deg);
}
.right .tail {
right: 0;
}
</style>

View file

@ -83,7 +83,7 @@ function leaveHover(): void {
> article {
> footer {
&:before {
&::before {
opacity: 1;
}
}
@ -139,7 +139,7 @@ function leaveHover(): void {
text-shadow: 0 0 8px #000;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
&:before {
&::before {
content: "";
display: block;
position: absolute;

View file

@ -14,8 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:enterToClass="defaultStore.state.animation && props.transition?.enterToClass || undefined"
:leaveFromClass="defaultStore.state.animation && props.transition?.leaveFromClass || undefined"
>
<canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined"/>
<img v-show="!hide" key="img" ref="img" :height="imgHeight" :width="imgWidth" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async"/>
<canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined" tabindex="-1"/>
<img v-show="!hide" key="img" ref="img" :height="imgHeight ?? undefined" :width="imgWidth ?? undefined" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async" tabindex="-1"/>
</TransitionGroup>
</div>
</template>
@ -23,8 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts">
import DrawBlurhash from '@/workers/draw-blurhash?worker';
import TestWebGL2 from '@/workers/test-webgl2?worker';
import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch.js';
import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash.js';
import { WorkerMultiDispatch } from '@@/js/worker-multi-dispatch.js';
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
// Web Worker
@ -151,22 +151,26 @@ function drawImage(bitmap: CanvasImageSource) {
}
function drawAvg() {
if (!canvas.value || !props.hash) return;
if (!canvas.value) return;
const color = (props.hash != null && extractAvgColorFromBlurhash(props.hash)) || '#888';
const ctx = canvas.value.getContext('2d');
if (!ctx) return;
// avgColor
ctx.beginPath();
ctx.fillStyle = extractAvgColorFromBlurhash(props.hash) ?? '#888';
ctx.fillStyle = color;
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
}
async function draw() {
if (props.hash == null) return;
if (import.meta.env.MODE === 'test' && props.hash == null) return;
drawAvg();
if (props.hash == null) return;
if (props.onlyAvgColor) return;
const work = await canvasPromise;

View file

@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { onMounted, onUnmounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue';
import { debounce } from 'throttle-debounce';
import MkButton from '@/components/MkButton.vue';
import { useInterval } from '@/scripts/use-interval.js';
import { useInterval } from '@@/js/use-interval.js';
import { i18n } from '@/i18n.js';
import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js';
@ -79,7 +79,7 @@ const props = defineProps<{
const emit = defineEmits<{
(ev: 'change', _ev: KeyboardEvent): void;
(ev: 'keydown', _ev: KeyboardEvent): void;
(ev: 'enter'): void;
(ev: 'enter', _ev: KeyboardEvent): void;
(ev: 'update:modelValue', value: string | number): void;
}>();
@ -111,7 +111,7 @@ const onKeydown = (ev: KeyboardEvent) => {
emit('keydown', ev);
if (ev.code === 'Enter') {
emit('enter');
emit('enter', ev);
}
};

View file

@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed } from 'vue';
import { instanceName } from '@/config.js';
import { instanceName } from '@@/js/config.js';
import { instance as Instance } from '@/instance.js';
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';

View file

@ -11,8 +11,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="isExpired" style="color: var(--error)">{{ i18n.ts.expired }}</span>
<span v-else style="color: var(--success)">{{ i18n.ts.unused }}</span>
</template>
<template #footer>
<div class="_buttons">
<MkButton v-if="!invite.used && !isExpired" primary rounded @click="copyInviteCode()"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
<MkButton v-if="!invite.used || moderator" danger rounded @click="deleteCode()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</template>
<div class="_gaps_s" :class="$style.root">
<div :class="$style.root">
<div :class="$style.items">
<div>
<div :class="$style.label">{{ i18n.ts.invitationCode }}</div>
@ -49,10 +55,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<div><MkTime :time="invite.createdAt" mode="absolute"/></div>
</div>
</div>
<div :class="$style.buttons">
<MkButton v-if="!invite.used && !isExpired" primary rounded @click="copyInviteCode()"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
<MkButton v-if="!invite.used || moderator" danger rounded @click="deleteCode()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</div>
</MkFolder>
</template>
@ -62,7 +64,7 @@ import { computed } from 'vue';
import * as Misskey from 'misskey-js';
import MkFolder from '@/components/MkFolder.vue';
import MkButton from '@/components/MkButton.vue';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
@ -121,9 +123,4 @@ function copyInviteCode() {
width: var(--height);
height: var(--height);
}
.buttons {
display: flex;
gap: 8px;
}
</style>

View file

@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { } from 'vue';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal?.close()" @closed="emit('closed')">
<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()">
<div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }">
<div class="main">
<template v-for="item in items" :key="item.text">

View file

@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { defineAsyncComponent, ref } from 'vue';
import { url as local } from '@/config.js';
import { url as local } from '@@/js/config.js';
import { useTooltip } from '@/scripts/use-tooltip.js';
import * as os from '@/os.js';
import { isEnabledUrlPreview } from '@/instance.js';

View file

@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@contextmenu.stop
@keydown.stop
>
<button v-if="hide" :class="$style.hidden" @click="hide = false">
<button v-if="hide" :class="$style.hidden" @click="show">
<div :class="$style.hiddenTextWrapper">
<b v-if="audio.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.audio}${audio.size ? ' ' + bytes(audio.size) : ''})` : '' }}</b>
<b v-else style="display: block;"><i class="ti ti-music"></i> {{ defaultStore.state.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b>
@ -39,23 +39,37 @@ SPDX-License-Identifier: AGPL-3.0-only
<audio
ref="audioEl"
preload="metadata"
@keydown.prevent="() => {}"
>
<source :src="audio.url">
</audio>
<div :class="[$style.controlsChild, $style.controlsLeft]">
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
<button
:class="['_button', $style.controlButton]"
tabindex="-1"
@click.stop="togglePlayPause"
>
<i v-if="isPlaying" class="ti ti-player-pause-filled"></i>
<i v-else class="ti ti-player-play-filled"></i>
</button>
</div>
<div :class="[$style.controlsChild, $style.controlsRight]">
<button class="_button" :class="$style.controlButton" @click="showMenu">
<button
:class="['_button', $style.controlButton]"
tabindex="-1"
@click.stop="() => {}"
@mousedown.prevent.stop="showMenu"
>
<i class="ti ti-settings"></i>
</button>
</div>
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div>
<div :class="[$style.controlsChild, $style.controlsVolume]">
<button class="_button" :class="$style.controlButton" @click="toggleMute">
<button
:class="['_button', $style.controlButton]"
tabindex="-1"
@click.stop="toggleMute"
>
<i v-if="volume === 0" class="ti ti-volume-3"></i>
<i v-else class="ti ti-volume"></i>
</button>
@ -80,6 +94,7 @@ import type { MenuItem } from '@/types/menu.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { type Keymap } from '@/scripts/hotkey.js';
import bytes from '@/filters/bytes.js';
import { hms } from '@/filters/hms.js';
import MkMediaRange from '@/components/MkMediaRange.vue';
@ -90,32 +105,44 @@ const props = defineProps<{
}>();
const keymap = {
'up': () => {
if (hasFocus() && audioEl.value) {
volume.value = Math.min(volume.value + 0.1, 1);
}
'up': {
allowRepeat: true,
callback: () => {
if (hasFocus() && audioEl.value) {
volume.value = Math.min(volume.value + 0.1, 1);
}
},
},
'down': () => {
if (hasFocus() && audioEl.value) {
volume.value = Math.max(volume.value - 0.1, 0);
}
'down': {
allowRepeat: true,
callback: () => {
if (hasFocus() && audioEl.value) {
volume.value = Math.max(volume.value - 0.1, 0);
}
},
},
'left': () => {
if (hasFocus() && audioEl.value) {
audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0);
}
'left': {
allowRepeat: true,
callback: () => {
if (hasFocus() && audioEl.value) {
audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0);
}
},
},
'right': () => {
if (hasFocus() && audioEl.value) {
audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration);
}
'right': {
allowRepeat: true,
callback: () => {
if (hasFocus() && audioEl.value) {
audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration);
}
},
},
'space': () => {
if (hasFocus()) {
togglePlayPause();
}
},
};
} as const satisfies Keymap;
// PlayerEl
function hasFocus() {
@ -129,13 +156,23 @@ const audioEl = shallowRef<HTMLAudioElement>();
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore'));
async function show() {
if (props.audio.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts.sensitiveMediaRevealConfirm,
});
if (canceled) return;
}
hide.value = false;
}
// Menu
const menuShowing = ref(false);
function showMenu(ev: MouseEvent) {
let menu: MenuItem[] = [];
menu = [
const menu: MenuItem[] = [
// TODO:
{
type: 'switch',
@ -183,7 +220,7 @@ function showMenu(ev: MouseEvent) {
menu.push({
type: 'divider',
}, {
type: 'link' as const,
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.audio.id}`,
@ -358,7 +395,7 @@ onDeactivated(() => {
border-radius: var(--radius);
overflow: clip;
&:focus {
&:focus-visible {
outline: none;
}
}
@ -424,6 +461,10 @@ onDeactivated(() => {
color: var(--accent);
background-color: var(--accentedBg);
}
&:focus-visible {
outline: none;
}
}
}

View file

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.root">
<MkMediaAudio v-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media"/>
<div v-else-if="media.isSensitive && hide" :class="$style.sensitive" @click="hide = false">
<div v-else-if="media.isSensitive && hide" :class="$style.sensitive" @click="show">
<span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span>
<b>{{ i18n.ts.sensitive }}</b>
<span>{{ i18n.ts.clickToShow }}</span>
@ -24,24 +24,30 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { shallowRef, watch, ref } from 'vue';
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';
import * as os from '@/os.js';
import MkMediaAudio from '@/components/MkMediaAudio.vue';
const props = withDefaults(defineProps<{
const props = defineProps<{
media: Misskey.entities.DriveFile;
}>(), {
});
}>();
const audioEl = shallowRef<HTMLAudioElement>();
const hide = ref(true);
watch(audioEl, () => {
if (audioEl.value) {
audioEl.value.volume = 0.3;
async function show() {
if (props.media.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts.sensitiveMediaRevealConfirm,
});
if (canceled) return;
}
});
hide.value = false;
}
</script>
<style lang="scss" module>

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive]" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'" @click="onclick">
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive]" @click="onclick">
<component
:is="disableImageLink ? 'div' : 'a'"
v-bind="disableImageLink ? {
@ -53,6 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { watch, ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import type { MenuItem } from '@/types/menu.js';
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
import bytes from '@/filters/bytes.js';
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
@ -74,7 +75,6 @@ const props = withDefaults(defineProps<{
});
const hide = ref(true);
const darkMode = ref<boolean>(defaultStore.state.darkMode);
const url = computed(() => (props.raw || defaultStore.state.loadRawImages)
? props.image.url
@ -83,11 +83,21 @@ const url = computed(() => (props.raw || defaultStore.state.loadRawImages)
: props.image.thumbnailUrl,
);
function onclick() {
async function onclick(ev: MouseEvent) {
if (!props.controls) {
return;
}
if (hide.value) {
ev.stopPropagation();
if (props.image.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts.sensitiveMediaRevealConfirm,
});
if (canceled) return;
}
hide.value = false;
}
}
@ -101,27 +111,39 @@ watch(() => props.image, () => {
});
function showMenu(ev: MouseEvent) {
os.popupMenu([{
const menuItems: MenuItem[] = [];
menuItems.push({
text: i18n.ts.hide,
icon: 'ti ti-eye-off',
action: () => {
hide.value = true;
},
}, ...(iAmModerator ? [{
text: i18n.ts.markAsSensitive,
icon: 'ti ti-eye-exclamation',
danger: true,
action: () => {
os.apiWithDialog('drive/files/update', { fileId: props.image.id, isSensitive: true });
},
}] : []), ...($i?.id === props.image.userId ? [{
type: 'divider' as const,
}, {
type: 'link' as const,
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.image.id}`,
}] : [])], ev.currentTarget ?? ev.target);
});
if (iAmModerator) {
menuItems.push({
text: i18n.ts.markAsSensitive,
icon: 'ti ti-eye-exclamation',
danger: true,
action: () => {
os.apiWithDialog('drive/files/update', { fileId: props.image.id, isSensitive: true });
},
});
}
if ($i?.id === props.image.userId) {
menuItems.push({
type: 'divider',
}, {
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.image.id}`,
});
}
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
}
</script>
@ -186,10 +208,19 @@ function showMenu(ev: MouseEvent) {
position: relative;
//box-shadow: 0 0 0 1px var(--divider) inset;
background: var(--bg);
background-image: linear-gradient(45deg, var(--c) 16.67%, var(--bg) 16.67%, var(--bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--bg) 66.67%, var(--bg) 100%);
background-size: 16px 16px;
}
html[data-color-scheme=dark] .visible {
--c: rgb(255 255 255 / 2%);
background-image: linear-gradient(45deg, var(--c) 16.67%, var(--bg) 16.67%, var(--bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--bg) 66.67%, var(--bg) 100%);
}
html[data-color-scheme=light] .visible {
--c: rgb(0 0 0 / 2%);
background-image: linear-gradient(45deg, var(--c) 16.67%, var(--bg) 16.67%, var(--bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--bg) 66.67%, var(--bg) 100%);
}
.menu {
display: block;
position: absolute;

View file

@ -37,8 +37,9 @@ import XBanner from '@/components/MkMediaBanner.vue';
import XImage from '@/components/MkMediaImage.vue';
import XVideo from '@/components/MkMediaVideo.vue';
import * as os from '@/os.js';
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
import { defaultStore } from '@/store.js';
import { focusParent } from '@/scripts/focus.js';
const props = defineProps<{
mediaList: Misskey.entities.DriveFile[];
@ -49,7 +50,9 @@ const gallery = shallowRef<HTMLDivElement>();
const pswpZIndex = os.claimZIndex('middle');
document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString());
const count = computed(() => props.mediaList.filter(media => previewable(media)).length);
let lightbox: PhotoSwipeLightbox | null;
let lightbox: PhotoSwipeLightbox | null = null;
let activeEl: HTMLElement | null = null;
const popstateHandler = (): void => {
if (lightbox?.pswp && lightbox.pswp.isOpen === true) {
@ -60,7 +63,7 @@ const popstateHandler = (): void => {
async function calcAspectRatio() {
if (!gallery.value) return;
let img = props.mediaList[0];
const img = props.mediaList[0];
if (props.mediaList.length !== 1 || !(img.properties.width && img.properties.height)) {
gallery.value.style.aspectRatio = '';
@ -131,18 +134,17 @@ onMounted(() => {
bgOpacity: 1,
showAnimationDuration: 100,
hideAnimationDuration: 100,
returnFocus: false,
pswpModule: PhotoSwipe,
});
lightbox.on('itemData', (ev) => {
const { itemData } = ev;
lightbox.addFilter('itemData', (itemData) => {
// element is children
const { element } = itemData;
const id = element?.dataset.id;
const file = props.mediaList.find(media => media.id === id);
if (!file) return;
if (!file) return itemData;
itemData.src = file.url;
itemData.w = Number(file.properties.width);
@ -154,44 +156,54 @@ onMounted(() => {
itemData.alt = file.comment ?? file.name;
itemData.comment = file.comment ?? file.name;
itemData.thumbCropped = true;
return itemData;
});
lightbox.on('uiRegister', () => {
lightbox?.pswp?.ui?.registerElement({
name: 'altText',
className: 'pwsp__alt-text-container',
className: 'pswp__alt-text-container',
appendTo: 'wrapper',
onInit: (el, pwsp) => {
let textBox = document.createElement('p');
textBox.className = 'pwsp__alt-text _acrylic';
onInit: (el, pswp) => {
const textBox = document.createElement('p');
textBox.className = 'pswp__alt-text _acrylic';
el.appendChild(textBox);
pwsp.on('change', () => {
textBox.textContent = pwsp.currSlide?.data.comment;
pswp.on('change', () => {
textBox.textContent = pswp.currSlide?.data.comment;
});
},
});
});
lightbox.init();
window.addEventListener('popstate', popstateHandler);
lightbox.on('beforeOpen', () => {
lightbox.on('afterInit', () => {
activeEl = document.activeElement instanceof HTMLElement ? document.activeElement : null;
focusParent(activeEl, true, true);
lightbox?.pswp?.element?.focus({
preventScroll: true,
});
history.pushState(null, '', '#pswp');
});
lightbox.on('close', () => {
lightbox.on('destroy', () => {
focusParent(activeEl, true, false);
activeEl = null;
if (window.location.hash === '#pswp') {
history.back();
}
});
window.addEventListener('popstate', popstateHandler);
lightbox.init();
});
onUnmounted(() => {
window.removeEventListener('popstate', popstateHandler);
lightbox?.destroy();
lightbox = null;
activeEl = null;
});
const previewable = (file: Misskey.entities.DriveFile): boolean => {
@ -199,6 +211,16 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
// FILE_TYPE_BROWSERSAFE
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
};
const openGallery = () => {
if (props.mediaList.filter(media => previewable(media)).length > 0) {
lightbox?.loadAndOpen(0);
}
};
defineExpose({
openGallery,
});
</script>
<style lang="scss" module>
@ -298,7 +320,7 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
backdrop-filter: var(--modalBgFilter);
}
.pwsp__alt-text-container {
.pswp__alt-text-container {
display: flex;
flex-direction: row;
align-items: center;
@ -312,7 +334,7 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
max-width: 800px;
}
.pwsp__alt-text {
.pswp__alt-text {
color: var(--fg);
margin: 0 auto;
text-align: center;

View file

@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@contextmenu.stop
@keydown.stop
>
<button v-if="hide" :class="$style.hidden" @click="hide = false">
<button v-if="hide" :class="$style.hidden" @click="show">
<div :class="$style.hiddenTextWrapper">
<b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b>
<b v-else style="display: block;"><i class="ti ti-movie"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b>
@ -112,6 +112,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref, shallowRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
import * as Misskey from 'misskey-js';
import type { MenuItem } from '@/types/menu.js';
import { type Keymap } from '@/scripts/hotkey.js';
import bytes from '@/filters/bytes.js';
import { hms } from '@/filters/hms.js';
import { defaultStore } from '@/store.js';
@ -127,32 +128,44 @@ const props = defineProps<{
}>();
const keymap = {
'up': () => {
if (hasFocus() && videoEl.value) {
volume.value = Math.min(volume.value + 0.1, 1);
}
'up': {
allowRepeat: true,
callback: () => {
if (hasFocus() && videoEl.value) {
volume.value = Math.min(volume.value + 0.1, 1);
}
},
},
'down': () => {
if (hasFocus() && videoEl.value) {
volume.value = Math.max(volume.value - 0.1, 0);
}
'down': {
allowRepeat: true,
callback: () => {
if (hasFocus() && videoEl.value) {
volume.value = Math.max(volume.value - 0.1, 0);
}
},
},
'left': () => {
if (hasFocus() && videoEl.value) {
videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0);
}
'left': {
allowRepeat: true,
callback: () => {
if (hasFocus() && videoEl.value) {
videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0);
}
},
},
'right': () => {
if (hasFocus() && videoEl.value) {
videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration);
}
'right': {
allowRepeat: true,
callback: () => {
if (hasFocus() && videoEl.value) {
videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration);
}
},
},
'space': () => {
if (hasFocus()) {
togglePlayPause();
}
},
};
} as const satisfies Keymap;
// PlayerEl
function hasFocus() {
@ -163,13 +176,23 @@ function hasFocus() {
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'));
async function show() {
if (props.video.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.ts.sensitiveMediaRevealConfirm,
});
if (canceled) return;
}
hide.value = false;
}
// Menu
const menuShowing = ref(false);
function showMenu(ev: MouseEvent) {
let menu: MenuItem[] = [];
menu = [
const menu: MenuItem[] = [
// TODO:
{
type: 'switch',
@ -222,7 +245,7 @@ function showMenu(ev: MouseEvent) {
menu.push({
type: 'divider',
}, {
type: 'link' as const,
type: 'link',
text: i18n.ts._fileViewer.title,
icon: 'ti ti-info-circle',
to: `/my/drive/file/${props.video.id}`,
@ -468,7 +491,7 @@ onDeactivated(() => {
position: relative;
overflow: clip;
&:focus {
&:focus-visible {
outline: none;
}
}
@ -575,6 +598,10 @@ onDeactivated(() => {
border-radius: 99rem;
font-size: 1.1rem;
&:focus-visible {
outline: none;
}
}
.videoLoading {
@ -638,6 +665,10 @@ onDeactivated(() => {
&:hover {
background-color: var(--accent);
}
&:focus-visible {
outline: none;
}
}
}

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkA v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :style="{ background: bgCss }" :behavior="navigationBehavior">
<MkA v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :behavior="navigationBehavior">
<img :class="$style.icon" :src="avatarUrl" alt="">
<span>
<span>@{{ username }}</span>
@ -16,8 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { toUnicode } from 'punycode';
import { computed } from 'vue';
import tinycolor from 'tinycolor2';
import { host as localHost } from '@/config.js';
import { host as localHost } from '@@/js/config.js';
import { $i } from '@/account.js';
import { defaultStore } from '@/store.js';
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
@ -37,11 +36,7 @@ const isMe = $i && (
`@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase()
);
const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention'));
bg.setAlpha(0.1);
const bgCss = bg.toRgbString();
const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages
const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar
? getStaticImageUrl(`/avatar/@${props.username}@${props.host}`)
: `/avatar/@${props.username}@${props.host}`,
);
@ -53,9 +48,11 @@ const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages
padding: 4px 8px 4px 4px;
border-radius: 999px;
color: var(--mention);
background: color(from var(--mention) srgb r g b / 0.1);
&.isMe {
color: var(--mentionMe);
background: color(from var(--mentionMe) srgb r g b / 0.1);
}
}

View file

@ -10,16 +10,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue';
import { nextTick, onMounted, onUnmounted, provide, shallowRef, watch } from 'vue';
import MkMenu from './MkMenu.vue';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
const props = defineProps<{
items: MenuItem[];
targetElement: HTMLElement;
rootElement: HTMLElement;
width?: number;
viaKeyboard?: boolean;
}>();
const emit = defineEmits<{
@ -27,6 +26,8 @@ const emit = defineEmits<{
(ev: 'actioned'): void;
}>();
provide('isNestingMenu', true);
const el = shallowRef<HTMLElement>();
const align = 'left';

View file

@ -4,23 +4,47 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div role="menu">
<div
role="menu"
:class="{
[$style.root]: true,
[$style.center]: align === 'center',
[$style.big]: big,
[$style.asDrawer]: asDrawer,
}"
@focusin.passive.stop="() => {}"
>
<div
ref="itemsEl" v-hotkey="keymap"
ref="itemsEl"
v-hotkey="keymap"
tabindex="0"
class="_popup _shadow"
:class="[$style.root, { [$style.center]: align === 'center', [$style.asDrawer]: asDrawer }]"
:style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }"
@contextmenu.self="e => e.preventDefault()"
:class="$style.menu"
:style="{
width: (width && !asDrawer) ? `${width}px` : '',
maxHeight: maxHeight ? `min(${maxHeight}px, calc(100dvh - 32px))` : 'calc(100dvh - 32px)',
}"
@keydown.stop="() => {}"
@contextmenu.self.prevent="() => {}"
>
<template v-for="(item, i) in (items2 ?? [])">
<div v-if="item.type === 'divider'" role="separator" :class="$style.divider"></div>
<span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]">
<template v-for="item in (items2 ?? [])">
<div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div>
<span v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label, $style.item]">
<span style="opacity: 0.7;">{{ item.text }}</span>
</span>
<span v-else-if="item.type === 'pending'" role="menuitem" :tabindex="i" :class="[$style.pending, $style.item]">
<span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item]">
<span><MkEllipsis/></span>
</span>
<MkA v-else-if="item.type === 'link'" role="menuitem" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<MkA
v-else-if="item.type === 'link'"
role="menuitem"
tabindex="0"
:class="['_button', $style.item]"
:to="item.to"
@click.passive="close(true)"
@mouseenter.passive="onItemMouseEnter"
@mouseleave.passive="onItemMouseLeave"
>
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
<div :class="$style.item_content">
@ -28,20 +52,49 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
</div>
</MkA>
<a v-else-if="item.type === 'a'" role="menuitem" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<a
v-else-if="item.type === 'a'"
role="menuitem"
tabindex="0"
:class="['_button', $style.item]"
:href="item.href"
:target="item.target"
:rel="item.target === '_blank' ? 'noopener noreferrer' : undefined"
:download="item.download"
@click.passive="close(true)"
@mouseenter.passive="onItemMouseEnter"
@mouseleave.passive="onItemMouseLeave"
>
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<div :class="$style.item_content">
<span :class="$style.item_content_text">{{ item.text }}</span>
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
</div>
</a>
<button v-else-if="item.type === 'user'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<button
v-else-if="item.type === 'user'"
role="menuitem"
tabindex="0"
:class="['_button', $style.item, { [$style.active]: item.active }]"
@click.prevent="item.active ? close(false) : clicked(item.action, $event)"
@mouseenter.passive="onItemMouseEnter"
@mouseleave.passive="onItemMouseLeave"
>
<MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/>
<div v-if="item.indicate" :class="$style.item_content">
<span :class="$style.indicator"><i class="_indicatorCircle"></i></span>
</div>
</button>
<button v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" class="_button" :class="[$style.item, $style.switch, { [$style.switchDisabled]: item.disabled } ]" @click="switchItem(item)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<button
v-else-if="item.type === 'switch'"
role="menuitemcheckbox"
tabindex="0"
:class="['_button', $style.item]"
:disabled="unref(item.disabled)"
@click.prevent="switchItem(item)"
@mouseenter.passive="onItemMouseEnter"
@mouseleave.passive="onItemMouseLeave"
>
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
<div :class="$style.item_content">
@ -49,29 +102,61 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
</div>
</button>
<button v-else-if="item.type === 'radio'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showRadioOptions(item, $event)" @click="!preferClick ? null : showRadioOptions(item, $event)">
<button
v-else-if="item.type === 'radio'"
role="menuitem"
tabindex="0"
:class="['_button', $style.item, $style.parent, { [$style.active]: childShowingItem === item }]"
:disabled="unref(item.disabled)"
@mouseenter.prevent="preferClick ? null : showRadioOptions(item, $event)"
@keydown.enter.prevent="preferClick ? null : showRadioOptions(item, $event)"
@click.prevent="!preferClick ? null : showRadioOptions(item, $event)"
>
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
<div :class="$style.item_content">
<span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
</div>
</button>
<button v-else-if="item.type === 'radioOption'" :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.radioActive]: item.active }]" @click="clicked(item.action, $event, false)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<button
v-else-if="item.type === 'radioOption'"
role="menuitemradio"
tabindex="0"
:class="['_button', $style.item, $style.radio, { [$style.active]: unref(item.active) }]"
@click.prevent="unref(item.active) ? null : clicked(item.action, $event, false)"
@mouseenter.passive="onItemMouseEnter"
@mouseleave.passive="onItemMouseLeave"
>
<div :class="$style.icon">
<span :class="[$style.radio, { [$style.radioChecked]: item.active }]"></span>
<span :class="[$style.radioIcon, { [$style.radioChecked]: unref(item.active) }]"></span>
</div>
<div :class="$style.item_content">
<span :class="$style.item_content_text">{{ item.text }}</span>
</div>
</button>
<button v-else-if="item.type === 'parent'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showChildren(item, $event)" @click="!preferClick ? null : showChildren(item, $event)">
<button
v-else-if="item.type === 'parent'"
role="menuitem"
tabindex="0"
:class="['_button', $style.item, $style.parent, { [$style.active]: childShowingItem === item }]"
@mouseenter.prevent="preferClick ? null : showChildren(item, $event)"
@keydown.enter.prevent="preferClick ? null : showChildren(item, $event)"
@click.prevent="!preferClick ? null : showChildren(item, $event)"
>
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
<div :class="$style.item_content">
<span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
</div>
</button>
<button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: getValue(item.active) }]" :disabled="getValue(item.active)" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<button
v-else role="menuitem"
tabindex="0"
:class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: unref(item.active) }]"
@click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)"
@mouseenter.passive="onItemMouseEnter"
@mouseleave.passive="onItemMouseLeave"
>
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
<div :class="$style.item_content">
@ -80,24 +165,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</button>
</template>
<span v-if="items2 == null || items2.length === 0" :class="[$style.none, $style.item]">
<span v-if="items2 == null || items2.length === 0" tabindex="-1" :class="[$style.none, $style.item]">
<span>{{ i18n.ts.none }}</span>
</span>
</div>
<div v-if="childMenu">
<XChild ref="child" :items="childMenu" :targetElement="childTarget!" :rootElement="itemsEl!" showing @actioned="childActioned" @close="close(false)"/>
<XChild ref="child" :items="childMenu" :targetElement="childTarget!" :rootElement="itemsEl!" @actioned="childActioned" @closed="closeChild"/>
</div>
</div>
</template>
<script lang="ts">
import { ComputedRef, computed, defineAsyncComponent, isRef, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
import { focusPrev, focusNext } from '@/scripts/focus.js';
import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, unref, watch } from 'vue';
import MkSwitchButton from '@/components/MkSwitch.button.vue';
import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { isTouchUsing } from '@/scripts/touch.js';
import { type Keymap } from '@/scripts/hotkey.js';
import { isFocusable } from '@/scripts/focus.js';
import { getNodeOrNull } from '@/scripts/get-dom-node-or-null.js';
const childrenCache = new WeakMap<MenuParent, MenuItem[]>();
</script>
@ -107,7 +194,6 @@ const XChild = defineAsyncComponent(() => import('./MkMenu.child.vue'));
const props = defineProps<{
items: MenuItem[];
viaKeyboard?: boolean;
asDrawer?: boolean;
align?: 'center' | string;
width?: number;
@ -119,17 +205,30 @@ const emit = defineEmits<{
(ev: 'hide'): void;
}>();
const itemsEl = shallowRef<HTMLDivElement>();
const big = isTouchUsing;
const isNestingMenu = inject<boolean>('isNestingMenu', false);
const itemsEl = shallowRef<HTMLElement>();
const items2 = ref<InnerMenuItem[]>();
const child = shallowRef<InstanceType<typeof XChild>>();
const keymap = computed(() => ({
'up|k|shift+tab': focusUp,
'down|j|tab': focusDown,
'esc': close,
}));
const keymap = {
'up|k|shift+tab': {
allowRepeat: true,
callback: () => focusUp(),
},
'down|j|tab': {
allowRepeat: true,
callback: () => focusDown(),
},
'esc': {
allowRepeat: true,
callback: () => close(false),
},
} as const satisfies Keymap;
const childShowingItem = ref<MenuItem | null>();
@ -167,25 +266,19 @@ function childActioned() {
close(true);
}
const onGlobalMousedown = (event: MouseEvent) => {
if (childTarget.value && (event.target === childTarget.value || childTarget.value.contains(event.target as Node))) return;
if (child.value && child.value.checkHit(event)) return;
closeChild();
};
let childCloseTimer: null | number = null;
function onItemMouseEnter(item) {
function onItemMouseEnter() {
childCloseTimer = window.setTimeout(() => {
closeChild();
}, 300);
}
function onItemMouseLeave(item) {
function onItemMouseLeave() {
if (childCloseTimer) window.clearTimeout(childCloseTimer);
}
async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
async function showRadioOptions(item: MenuRadio, ev: Event) {
const children: MenuItem[] = Object.keys(item.options).map<MenuRadioOption>(key => {
const value = item.options[key];
return {
@ -200,7 +293,7 @@ async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
if (props.asDrawer) {
os.popupMenu(children, ev.currentTarget ?? ev.target).finally(() => {
emit('close');
close(false);
});
emit('hide');
} else {
@ -210,7 +303,9 @@ async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
}
}
async function showChildren(item: MenuParent, ev: MouseEvent) {
async function showChildren(item: MenuParent, ev: Event) {
ev.stopPropagation();
const children: MenuItem[] = await (async () => {
if (childrenCache.has(item)) {
return childrenCache.get(item)!;
@ -227,7 +322,7 @@ async function showChildren(item: MenuParent, ev: MouseEvent) {
if (props.asDrawer) {
os.popupMenu(children, ev.currentTarget ?? ev.target).finally(() => {
emit('close');
close(false);
});
emit('hide');
} else {
@ -246,15 +341,11 @@ function clicked(fn: MenuAction, ev: MouseEvent, doClose = true) {
}
function close(actioned = false) {
emit('close', actioned);
}
function focusUp() {
focusPrev(document.activeElement);
}
function focusDown() {
focusNext(document.activeElement);
disposeHandlers();
nextTick(() => {
closeChild();
emit('close', actioned);
});
}
function switchItem(item: MenuSwitch & { ref: any }) {
@ -262,30 +353,132 @@ function switchItem(item: MenuSwitch & { ref: any }) {
item.ref = !item.ref;
}
function getValue<T>(item?: ComputedRef<T> | T) {
return isRef(item) ? item.value : item;
function focusUp() {
if (disposed) return;
if (!itemsEl.value?.contains(document.activeElement)) return;
const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable);
const activeIndex = focusableElements.findIndex(el => el === document.activeElement);
const targetIndex = (activeIndex !== -1 && activeIndex !== 0) ? (activeIndex - 1) : (focusableElements.length - 1);
const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value;
targetElement.focus();
}
onMounted(() => {
if (props.viaKeyboard) {
nextTick(() => {
if (itemsEl.value) focusNext(itemsEl.value.children[0], true, false);
});
function focusDown() {
if (disposed) return;
if (!itemsEl.value?.contains(document.activeElement)) return;
const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable);
const activeIndex = focusableElements.findIndex(el => el === document.activeElement);
const targetIndex = (activeIndex !== -1 && activeIndex !== (focusableElements.length - 1)) ? (activeIndex + 1) : 0;
const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value;
targetElement.focus();
}
const onGlobalFocusin = (ev: FocusEvent) => {
if (disposed) return;
if (itemsEl.value?.parentElement?.contains(getNodeOrNull(ev.target))) return;
nextTick(() => {
if (itemsEl.value != null && isFocusable(itemsEl.value)) {
itemsEl.value.focus({ preventScroll: true });
nextTick(() => focusDown());
}
});
};
const onGlobalMousedown = (ev: MouseEvent) => {
if (disposed) return;
if (childTarget.value?.contains(getNodeOrNull(ev.target))) return;
if (child.value?.checkHit(ev)) return;
closeChild();
};
const setupHandlers = () => {
if (!isNestingMenu) {
document.addEventListener('focusin', onGlobalFocusin, { passive: true });
}
// TODO:
//itemsEl.scrollTo();
document.addEventListener('mousedown', onGlobalMousedown, { passive: true });
};
let disposed = false;
const disposeHandlers = () => {
disposed = true;
if (!isNestingMenu) {
document.removeEventListener('focusin', onGlobalFocusin);
}
document.removeEventListener('mousedown', onGlobalMousedown);
};
onMounted(() => {
setupHandlers();
if (!isNestingMenu) {
nextTick(() => itemsEl.value?.focus({ preventScroll: true }));
}
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onGlobalMousedown);
disposeHandlers();
});
</script>
<style lang="scss" module>
.root {
&.center {
> .menu {
> .item {
text-align: center;
}
}
}
&.big:not(.asDrawer) {
> .menu {
> .item {
padding: 6px 20px;
font-size: 1em;
line-height: 24px;
}
}
}
&.asDrawer {
max-width: 600px;
margin: auto;
> .menu {
padding: 12px 0 max(env(safe-area-inset-bottom, 0px), 12px) 0;
width: 100%;
border-radius: 24px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
> .item {
font-size: 1em;
padding: 12px 24px;
&::before {
width: calc(100% - 24px);
border-radius: 12px;
}
> .icon {
margin-right: 14px;
width: 24px;
}
}
> .divider {
margin: 12px 0;
}
}
}
}
.menu {
padding: 8px 0;
box-sizing: border-box;
max-width: 100vw;
@ -293,37 +486,8 @@ onBeforeUnmount(() => {
overflow: auto;
overscroll-behavior: contain;
&.center {
> .item {
text-align: center;
}
}
&.asDrawer {
padding: 12px 0 max(env(safe-area-inset-bottom, 0px), 12px) 0;
width: 100%;
border-radius: 24px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
> .item {
font-size: 1em;
padding: 12px 24px;
&:before {
width: calc(100% - 24px);
border-radius: 12px;
}
> .icon {
margin-right: 14px;
width: 24px;
}
}
> .divider {
margin: 12px 0;
}
&:focus-visible {
outline: none;
}
}
@ -340,8 +504,10 @@ onBeforeUnmount(() => {
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none !important;
color: var(--menuFg, var(--fg));
&:before {
&::before {
content: "";
display: block;
position: absolute;
@ -355,56 +521,56 @@ onBeforeUnmount(() => {
border-radius: 6px;
}
&:not(:disabled):hover {
color: var(--accent);
text-decoration: none;
&:focus-visible {
outline: none;
&:before {
background: var(--accentedBg);
&:not(:hover):not(:active)::before {
outline: var(--focus) solid 2px;
outline-offset: -2px;
}
}
&:not(:disabled) {
&:hover,
&:focus-visible:active,
&:focus-visible.active {
color: var(--menuHoverFg, var(--accent));
&::before {
background-color: var(--menuHoverBg, var(--accentedBg));
}
}
&:not(:focus-visible):active,
&:not(:focus-visible).active {
color: var(--menuActiveFg, var(--fgOnAccent));
&::before {
background-color: var(--menuActiveBg, var(--accent));
}
}
}
&:disabled {
cursor: not-allowed;
}
&.danger {
color: #ff2a2a;
&:hover {
color: #fff;
&:before {
background: #ff4242;
}
}
&:active {
color: #fff;
&:before {
background: #d42e2e !important;
}
}
--menuFg: #ff2a2a;
--menuHoverFg: #fff;
--menuHoverBg: #ff4242;
--menuActiveFg: #fff;
--menuActiveBg: #d42e2e;
}
&:active,
&.active {
color: var(--fgOnAccent) !important;
opacity: 1;
&:before {
background: var(--accent) !important;
}
&.radio {
--menuActiveFg: var(--accent);
--menuActiveBg: var(--accentedBg);
}
&.radioActive {
color: var(--accent) !important;
opacity: 1;
&:before {
background-color: var(--accentedBg) !important;
}
}
&:not(:active):focus-visible {
box-shadow: 0 0 0 2px var(--focus) inset;
&.parent {
--menuActiveFg: var(--accent);
--menuActiveBg: var(--accentedBg);
}
&.label {
@ -422,22 +588,6 @@ onBeforeUnmount(() => {
pointer-events: none;
opacity: 0.7;
}
&.parent {
pointer-events: auto;
display: flex;
align-items: center;
cursor: default;
&.childShowing {
color: var(--accent);
text-decoration: none;
&:before {
background: var(--accentedBg);
}
}
}
}
.item_content {
@ -456,18 +606,6 @@ onBeforeUnmount(() => {
overflow: hidden;
}
.switch {
position: relative;
display: flex;
transition: all 0.2s ease;
user-select: none;
cursor: pointer;
}
.switchDisabled {
cursor: not-allowed;
}
.switchButton {
margin-left: -2px;
--height: 1.35em;
@ -479,14 +617,6 @@ onBeforeUnmount(() => {
text-overflow: ellipsis;
}
.switchInput {
position: absolute;
width: 0;
height: 0;
opacity: 0;
margin: 0;
}
.icon {
margin-right: 8px;
line-height: 1;
@ -515,12 +645,12 @@ onBeforeUnmount(() => {
border-top: solid 0.5px var(--divider);
}
.radio {
.radioIcon {
display: inline-block;
position: relative;
width: 1em;
height: 1em;
vertical-align: -.125em;
vertical-align: -0.125em;
border-radius: 50%;
border: solid 2px var(--divider);
background-color: var(--panel);

View file

@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { watch, ref } from 'vue';
import { v4 as uuid } from 'uuid';
import tinycolor from 'tinycolor2';
import { useInterval } from '@/scripts/use-interval.js';
import { useInterval } from '@@/js/use-interval.js';
const props = defineProps<{
src: number[];

View file

@ -30,9 +30,9 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.transition_modal_leaveTo]: transitionName === 'modal',
[$style.transition_send_leaveTo]: transitionName === 'send',
})"
:duration="transitionDuration" appear @afterLeave="emit('closed')" @enter="emit('opening')" @afterEnter="onOpened"
:duration="transitionDuration" appear @afterLeave="onClosed" @enter="emit('opening')" @afterEnter="onOpened"
>
<div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
<div v-show="manualShowing != null ? manualShowing : showing" ref="modalRootEl" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
<div data-cy-bg :data-cy-transparent="isEnableBgTransparent" class="_modalBg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
<div ref="content" :class="[$style.content, { [$style.fixed]: fixed }]" :style="{ zIndex }" @click.self="onBgClick">
<slot :max-height="maxHeight" :type="type"></slot>
@ -47,6 +47,9 @@ import * as os from '@/os.js';
import { isTouchUsing } from '@/scripts/touch.js';
import { defaultStore } from '@/store.js';
import { deviceKind } from '@/scripts/device-kind.js';
import { type Keymap } from '@/scripts/hotkey.js';
import { focusTrap } from '@/scripts/focus-trap.js';
import { focusParent } from '@/scripts/focus.js';
function getFixedContainer(el: Element | null): Element | null {
if (el == null || el.tagName === 'BODY') return null;
@ -68,6 +71,8 @@ const props = withDefaults(defineProps<{
zPriority?: 'low' | 'middle' | 'high';
noOverlap?: boolean;
transparentBg?: boolean;
hasInteractionWithOtherFocusTrappedEls?: boolean;
returnFocusTo?: HTMLElement | null;
}>(), {
manualShowing: null,
src: null,
@ -76,6 +81,8 @@ const props = withDefaults(defineProps<{
zPriority: 'low',
noOverlap: true,
transparentBg: false,
hasInteractionWithOtherFocusTrappedEls: false,
returnFocusTo: null,
});
const emit = defineEmits<{
@ -93,12 +100,13 @@ const maxHeight = ref<number>();
const fixed = ref(false);
const transformOrigin = ref('center');
const showing = ref(true);
const modalRootEl = shallowRef<HTMLElement>();
const content = shallowRef<HTMLElement>();
const zIndex = os.claimZIndex(props.zPriority);
const useSendAnime = ref(false);
const type = computed<ModalTypes>(() => {
if (props.preferType === 'auto') {
if (!defaultStore.state.disableDrawer && isTouchUsing && deviceKind === 'smartphone') {
if ((defaultStore.state.menuStyle === 'drawer') || (defaultStore.state.menuStyle === 'auto' && isTouchUsing && deviceKind === 'smartphone')) {
return 'drawer';
} else {
return props.src != null ? 'popup' : 'dialog';
@ -131,6 +139,7 @@ const transitionDuration = computed((() =>
: 0
));
let releaseFocusTrap: (() => void) | null = null;
let contentClicking = false;
function close(opts: { useSendAnimation?: boolean } = {}) {
@ -154,8 +163,11 @@ if (type.value === 'drawer') {
}
const keymap = {
'esc': () => emit('esc'),
};
'esc': {
allowRepeat: true,
callback: () => emit('esc'),
},
} as const satisfies Keymap;
const MARGIN = 16;
const SCROLLBAR_THICKNESS = 16;
@ -292,6 +304,10 @@ const onOpened = () => {
}, { passive: true });
};
const onClosed = () => {
emit('closed');
};
const alignObserver = new ResizeObserver((entries, observer) => {
align();
});
@ -309,6 +325,20 @@ onMounted(() => {
align();
}, { immediate: true });
watch([showing, () => props.manualShowing], ([showing, manualShowing]) => {
if (manualShowing === true || (manualShowing == null && showing === true)) {
if (modalRootEl.value != null) {
const { release } = focusTrap(modalRootEl.value, props.hasInteractionWithOtherFocusTrappedEls);
releaseFocusTrap = release;
modalRootEl.value.focus();
}
} else {
releaseFocusTrap?.();
focusParent(props.returnFocusTo ?? props.src, true, false);
}
}, { immediate: true });
nextTick(() => {
alignObserver.observe(content.value!);
});

View file

@ -4,15 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="$emit('closed')">
<div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }" @keydown="onKeydown">
<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')">
<div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }">
<div ref="headerEl" :class="$style.header">
<button v-if="withOkButton" :class="$style.headerButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
<button v-if="withOkButton && withCloseButton" :class="$style.headerButton" class="_button" @click="emit('close')"><i class="ti ti-x"></i></button>
<span :class="$style.title">
<slot name="header"></slot>
</span>
<button v-if="!withOkButton" :class="$style.headerButton" class="_button" data-cy-modal-window-close @click="$emit('close')"><i class="ti ti-x"></i></button>
<button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="$emit('ok')"><i class="ti ti-check"></i></button>
<button v-if="!withOkButton && withCloseButton" :class="$style.headerButton" class="_button" data-cy-modal-window-close @click="emit('close')"><i class="ti ti-x"></i></button>
<button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="emit('ok')"><i class="ti ti-check"></i></button>
</div>
<div :class="$style.body">
<slot :width="bodyWidth" :height="bodyHeight"></slot>
@ -27,11 +27,13 @@ import MkModal from './MkModal.vue';
const props = withDefaults(defineProps<{
withOkButton: boolean;
withCloseButton: boolean;
okButtonDisabled: boolean;
width: number;
height: number;
}>(), {
withOkButton: false,
withCloseButton: true,
okButtonDisabled: false,
width: 400,
height: 500,
@ -42,6 +44,7 @@ const emit = defineEmits<{
(event: 'close'): void;
(event: 'closed'): void;
(event: 'ok'): void;
(event: 'esc'): void;
}>();
const modal = shallowRef<InstanceType<typeof MkModal>>();
@ -50,21 +53,13 @@ const headerEl = shallowRef<HTMLElement>();
const bodyWidth = ref(0);
const bodyHeight = ref(0);
const close = () => {
function close() {
modal.value?.close();
};
}
const onBgClick = () => {
function onBgClick() {
emit('click');
};
const onKeydown = (evt) => {
if (evt.which === 27) { // Esc
evt.preventDefault();
evt.stopPropagation();
close();
}
};
}
const ro = new ResizeObserver((entries, observer) => {
if (rootEl.value == null || headerEl.value == null) return;
@ -99,12 +94,12 @@ defineExpose({
--root-margin: 24px;
--headerHeight: 46px;
--headerHeightNarrow: 42px;
@media (max-width: 500px) {
--root-margin: 16px;
}
--headerHeight: 46px;
--headerHeightNarrow: 42px;
}
.header {

View file

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="rootEl"
v-hotkey="keymap"
:class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]"
:tabindex="!isDeleted ? '-1' : undefined"
:tabindex="isDeleted ? '-1' : '0'"
>
<MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
<div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
</I18n>
<div :class="$style.renoteInfo">
<button ref="renoteTime" :class="$style.renoteTime" class="_button" @click="showRenoteMenu()">
<button ref="renoteTime" :class="$style.renoteTime" class="_button" @mousedown.prevent="showRenoteMenu()">
<i class="ti ti-dots" :class="$style.renoteMenu"></i>
<MkTime :time="note.createdAt"/>
</button>
@ -53,7 +53,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
<div style="container-type: inline-size;">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
<Mfm
v-if="appearNote.cw != ''"
:text="appearNote.cw"
:author="appearNote.user"
:nyaize="'respect'"
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
/>
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;"/>
</p>
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
@ -79,7 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList :mediaList="appearNote.files"/>
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
<div v-if="isEnabledUrlPreview">
@ -110,7 +117,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="renoteButton"
:class="$style.footerButton"
class="_button"
@mousedown="renote()"
@mousedown.prevent="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.renoteCount) }}</p>
@ -119,16 +126,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-ban"></i>
</button>
<button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--eventReactionHeart);"></i>
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--love);"></i>
<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button>
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()">
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="showMenu()">
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()">
<i class="ti ti-dots"></i>
</button>
</footer>
@ -163,6 +170,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, inject, onMounted, ref, shallowRef, Ref, watch, provide } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteHeader from '@/components/MkNoteHeader.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
@ -174,8 +182,7 @@ import MkPoll from '@/components/MkPoll.vue';
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { pleaseLogin } from '@/scripts/please-login.js';
import { focusPrev, focusNext } from '@/scripts/focus.js';
import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import number from '@/filters/number.js';
@ -193,11 +200,15 @@ import { deepClone } from '@/scripts/clone.js';
import { useTooltip } from '@/scripts/use-tooltip.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { getNoteSummary } from '@/scripts/get-note-summary.js';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
import { shouldCollapsed } from '@/scripts/collapsed.js';
import { shouldCollapsed } from '@@/js/collapsed.js';
import { host } from '@@/js/config.js';
import { isEnabledUrlPreview } from '@/instance.js';
import { type Keymap } from '@/scripts/hotkey.js';
import { focusPrev, focusNext } from '@/scripts/focus.js';
import { getAppearNote } from '@/scripts/get-appear-note.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -240,14 +251,7 @@ if (noteViewInterruptors.length > 0) {
});
}
const isRenote = (
note.value.renote != null &&
note.value.reply == null &&
note.value.text == null &&
note.value.cw == null &&
note.value.fileIds && note.value.fileIds.length === 0 &&
note.value.poll == null
);
const isRenote = Misskey.note.isPureRenote(note.value);
const rootEl = shallowRef<HTMLElement>();
const menuButton = shallowRef<HTMLElement>();
@ -255,7 +259,8 @@ const renoteButton = shallowRef<HTMLElement>();
const renoteTime = shallowRef<HTMLElement>();
const reactButton = shallowRef<HTMLElement>();
const clipButton = shallowRef<HTMLElement>();
const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
const appearNote = computed(() => getAppearNote(note.value));
const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(false);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
@ -276,6 +281,11 @@ const renoteCollapsed = ref(
),
);
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: `https://${host}/notes/${appearNote.value.id}`,
}));
/* Overload FunctionLint
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute';
@ -294,15 +304,53 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string
}
const keymap = {
'r': () => reply(true),
'e|a|plus': () => react(true),
'q': () => renote(true),
'up|k|shift+tab': focusBefore,
'down|j|tab': focusAfter,
'esc': blur,
'm|o': () => showMenu(true),
's': () => showContent.value !== showContent.value,
};
'r': () => {
if (renoteCollapsed.value) return;
reply();
},
'e|a|plus': () => {
if (renoteCollapsed.value) return;
react();
},
'q': () => {
if (renoteCollapsed.value) return;
renote();
},
'm': () => {
if (renoteCollapsed.value) return;
showMenu();
},
'c': () => {
if (renoteCollapsed.value) return;
if (!defaultStore.state.showClipButtonInNoteFooter) return;
clip();
},
'o': () => {
if (renoteCollapsed.value) return;
galleryEl.value?.openGallery();
},
'v|enter': () => {
if (renoteCollapsed.value) {
renoteCollapsed.value = false;
} else if (appearNote.value.cw != null) {
showContent.value = !showContent.value;
} else if (isLong) {
collapsed.value = !collapsed.value;
}
},
'esc': {
allowRepeat: true,
callback: () => blur(),
},
'up|k|shift+tab': {
allowRepeat: true,
callback: () => focusBefore(),
},
'down|j|tab': {
allowRepeat: true,
callback: () => focusAfter(),
},
} as const satisfies Keymap;
provide('react', (reaction: string) => {
misskeyApi('notes/reactions/create', {
@ -371,7 +419,7 @@ if (!props.mock) {
}
function renote(viaKeyboard = false) {
pleaseLogin();
pleaseLogin(undefined, pleaseLoginContext.value);
showMovedDialog();
const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock });
@ -380,22 +428,21 @@ function renote(viaKeyboard = false) {
});
}
function reply(viaKeyboard = false): void {
pleaseLogin();
function reply(): void {
pleaseLogin(undefined, pleaseLoginContext.value);
if (props.mock) {
return;
}
os.post({
reply: appearNote.value,
channel: appearNote.value.channel,
animation: !viaKeyboard,
}).then(() => {
focus();
});
}
function react(viaKeyboard = false): void {
pleaseLogin();
function react(): void {
pleaseLogin(undefined, pleaseLoginContext.value);
showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction');
@ -467,16 +514,6 @@ function onContextmenu(ev: MouseEvent): void {
return;
}
const isLink = (el: HTMLElement): boolean => {
if (el.tagName === 'A') return true;
// Audio
if (el.tagName === 'AUDIO') return true;
if (el.parentElement) {
return isLink(el.parentElement);
}
return false;
};
if (ev.target && isLink(ev.target as HTMLElement)) return;
if (window.getSelection()?.toString() !== '') return;
@ -489,18 +526,16 @@ function onContextmenu(ev: MouseEvent): void {
}
}
function showMenu(viaKeyboard = false): void {
function showMenu(): void {
if (props.mock) {
return;
}
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value });
os.popupMenu(menu, menuButton.value, {
viaKeyboard,
}).then(focus).finally(cleanup);
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
}
async function clip() {
async function clip(): Promise<void> {
if (props.mock) {
return;
}
@ -508,7 +543,7 @@ async function clip() {
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
}
function showRenoteMenu(viaKeyboard = false): void {
function showRenoteMenu(): void {
if (props.mock) {
return;
}
@ -528,23 +563,19 @@ function showRenoteMenu(viaKeyboard = false): void {
}
if (isMyRenote) {
pleaseLogin();
pleaseLogin(undefined, pleaseLoginContext.value);
os.popupMenu([
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
{ type: 'divider' },
getUnrenote(),
], renoteTime.value, {
viaKeyboard: viaKeyboard,
});
], renoteTime.value);
} else {
os.popupMenu([
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
{ type: 'divider' },
getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote),
($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined,
], renoteTime.value, {
viaKeyboard: viaKeyboard,
});
], renoteTime.value);
}
}
@ -557,11 +588,11 @@ function blur() {
}
function focusBefore() {
focusPrev(rootEl.value ?? null);
focusPrev(rootEl.value);
}
function focusAfter() {
focusNext(rootEl.value ?? null);
focusNext(rootEl.value);
}
function readPromo() {
@ -594,12 +625,12 @@ function emitUpdReaction(emoji: string, delta: number) {
//
// ()
//content-visibility: auto;
//contain-intrinsic-size: 0 128px;
//contain-intrinsic-size: 0 128px;
&:focus-visible {
outline: none;
&:after {
&::after {
content: "";
pointer-events: none;
display: block;
@ -612,7 +643,7 @@ function emitUpdReaction(emoji: string, delta: number) {
margin: auto;
width: calc(100% - 8px);
height: calc(100% - 8px);
border: dashed 1px var(--focus);
border: dashed 2px var(--focus);
border-radius: var(--radius);
box-sizing: border-box;
}
@ -826,7 +857,7 @@ function emitUpdReaction(emoji: string, delta: number) {
z-index: 2;
width: 100%;
height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15));
background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
&:hover > .collapsedLabel {
background: var(--panelHighlight);

View file

@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="rootEl"
v-hotkey="keymap"
:class="$style.root"
:tabindex="isDeleted ? '-1' : '0'"
>
<div v-if="appearNote.reply && appearNote.reply.replyId">
<div v-if="!conversationLoaded" style="padding: 16px">
@ -31,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</I18n>
</span>
<div :class="$style.renoteInfo">
<button ref="renoteTime" class="_button" :class="$style.renoteTime" @click="showRenoteMenu()">
<button ref="renoteTime" class="_button" :class="$style.renoteTime" @mousedown.prevent="showRenoteMenu()">
<i v-if="isMyRenote" class="ti ti-dots" style="margin-right: 4px;"></i>
<MkTime :time="note.createdAt"/>
</button>
@ -67,7 +68,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</header>
<div :class="$style.noteContent">
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
<Mfm
v-if="appearNote.cw != ''"
:text="appearNote.cw"
:author="appearNote.user"
:nyaize="'respect'"
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
/>
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/>
</p>
<div v-show="appearNote.cw == null || showContent">
@ -92,7 +100,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
<div v-if="appearNote.files && appearNote.files.length > 0">
<MkMediaList :mediaList="appearNote.files"/>
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
</div>
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
<div v-if="isEnabledUrlPreview">
@ -118,7 +126,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="renoteButton"
class="_button"
:class="$style.noteFooterButton"
@mousedown="renote()"
@mousedown.prevent="renote()"
>
<i class="ti ti-repeat"></i>
<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p>
@ -127,16 +135,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-ban"></i>
</button>
<button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()">
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--eventReactionHeart);"></i>
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--love);"></i>
<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--accent);"></i>
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
<i v-else class="ti ti-plus"></i>
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p>
</button>
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()">
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
<i class="ti ti-paperclip"></i>
</button>
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="showMenu()">
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
<i class="ti ti-dots"></i>
</button>
</footer>
@ -198,6 +206,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, inject, onMounted, provide, ref, shallowRef } from 'vue';
import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import { isLink } from '@@/js/is-link.js';
import MkNoteSub from '@/components/MkNoteSub.vue';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
@ -208,7 +217,7 @@ import MkPoll from '@/components/MkPoll.vue';
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import { pleaseLogin } from '@/scripts/please-login.js';
import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { userPage } from '@/filters/user.js';
import { notePage } from '@/filters/note.js';
@ -221,6 +230,7 @@ import { reactionPicker } from '@/scripts/reaction-picker.js';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
import { $i } from '@/account.js';
import { i18n } from '@/i18n.js';
import { host } from '@@/js/config.js';
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js';
import { useNoteCapture } from '@/scripts/use-note-capture.js';
import { deepClone } from '@/scripts/clone.js';
@ -233,6 +243,8 @@ import MkPagination, { type Paging } from '@/components/MkPagination.vue';
import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkButton from '@/components/MkButton.vue';
import { isEnabledUrlPreview } from '@/instance.js';
import { getAppearNote } from '@/scripts/get-appear-note.js';
import { type Keymap } from '@/scripts/hotkey.js';
const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
@ -264,14 +276,7 @@ if (noteViewInterruptors.length > 0) {
});
}
const isRenote = (
note.value.renote != null &&
note.value.reply == null &&
note.value.text == null &&
note.value.cw == null &&
note.value.fileIds && note.value.fileIds.length === 0 &&
note.value.poll == null
);
const isRenote = Misskey.note.isPureRenote(note.value);
const rootEl = shallowRef<HTMLElement>();
const menuButton = shallowRef<HTMLElement>();
@ -279,7 +284,8 @@ const renoteButton = shallowRef<HTMLElement>();
const renoteTime = shallowRef<HTMLElement>();
const reactButton = shallowRef<HTMLElement>();
const clipButton = shallowRef<HTMLElement>();
const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
const appearNote = computed(() => getAppearNote(note.value));
const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
const isMyRenote = $i && ($i.id === note.value.userId);
const showContent = ref(false);
const isDeleted = ref(false);
@ -293,14 +299,31 @@ const conversation = ref<Misskey.entities.Note[]>([]);
const replies = ref<Misskey.entities.Note[]>([]);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id);
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: `https://${host}/notes/${appearNote.value.id}`,
}));
const keymap = {
'r': () => reply(true),
'e|a|plus': () => react(true),
'q': () => renote(true),
'esc': blur,
'm|o': () => showMenu(true),
's': () => showContent.value !== showContent.value,
};
'r': () => reply(),
'e|a|plus': () => react(),
'q': () => renote(),
'm': () => showMenu(),
'c': () => {
if (!defaultStore.state.showClipButtonInNoteFooter) return;
clip();
},
'o': () => galleryEl.value?.openGallery(),
'v|enter': () => {
if (appearNote.value.cw != null) {
showContent.value = !showContent.value;
}
},
'esc': {
allowRepeat: true,
callback: () => blur(),
},
} as const satisfies Keymap;
provide('react', (reaction: string) => {
misskeyApi('notes/reactions/create', {
@ -380,30 +403,27 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') {
});
}
function renote(viaKeyboard = false) {
pleaseLogin();
function renote() {
pleaseLogin(undefined, pleaseLoginContext.value);
showMovedDialog();
const { menu } = getRenoteMenu({ note: note.value, renoteButton });
os.popupMenu(menu, renoteButton.value, {
viaKeyboard,
});
os.popupMenu(menu, renoteButton.value);
}
function reply(viaKeyboard = false): void {
pleaseLogin();
function reply(): void {
pleaseLogin(undefined, pleaseLoginContext.value);
showMovedDialog();
os.post({
reply: appearNote.value,
channel: appearNote.value.channel,
animation: !viaKeyboard,
}).then(() => {
focus();
});
}
function react(viaKeyboard = false): void {
pleaseLogin();
function react(): void {
pleaseLogin(undefined, pleaseLoginContext.value);
showMovedDialog();
if (appearNote.value.reactionAcceptance === 'likeOnly') {
sound.playMisskeySfx('reaction');
@ -412,7 +432,7 @@ function react(viaKeyboard = false): void {
noteId: appearNote.value.id,
reaction: '❤️',
});
const el = reactButton.value as HTMLElement | null | undefined;
const el = reactButton.value;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
@ -456,14 +476,6 @@ function toggleReact() {
}
function onContextmenu(ev: MouseEvent): void {
const isLink = (el: HTMLElement): boolean => {
if (el.tagName === 'A') return true;
if (el.parentElement) {
return isLink(el.parentElement);
}
return false;
};
if (ev.target && isLink(ev.target as HTMLElement)) return;
if (window.getSelection()?.toString() !== '') return;
@ -476,20 +488,18 @@ function onContextmenu(ev: MouseEvent): void {
}
}
function showMenu(viaKeyboard = false): void {
function showMenu(): void {
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted });
os.popupMenu(menu, menuButton.value, {
viaKeyboard,
}).then(focus).finally(cleanup);
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
}
async function clip() {
async function clip(): Promise<void> {
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus);
}
function showRenoteMenu(viaKeyboard = false): void {
function showRenoteMenu(): void {
if (!isMyRenote) return;
pleaseLogin();
pleaseLogin(undefined, pleaseLoginContext.value);
os.popupMenu([{
text: i18n.ts.unrenote,
icon: 'ti ti-trash',
@ -500,9 +510,7 @@ function showRenoteMenu(viaKeyboard = false): void {
});
isDeleted.value = true;
},
}], renoteTime.value, {
viaKeyboard: viaKeyboard,
});
}], renoteTime.value);
}
function focus() {
@ -544,6 +552,28 @@ function loadConversation() {
transition: box-shadow 0.1s ease;
overflow: clip;
contain: content;
&:focus-visible {
outline: none;
&::after {
content: "";
pointer-events: none;
display: block;
position: absolute;
z-index: 10;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
width: calc(100% - 8px);
height: calc(100% - 8px);
border: dashed 2px var(--focus);
border-radius: var(--radius);
box-sizing: border-box;
}
}
}
.replyTo {

View file

@ -5,14 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<header :class="$style.root">
<div v-if="mock" :class="$style.name">
<MkUserName :user="note.user"/>
</div>
<MkA v-else v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)">
<MkUserName :user="note.user"/>
</MkA>
<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
<div :class="$style.username"><MkAcct :user="note.user"/></div>
<component :is="defaultStore.state.enableCondensedLine ? 'MkCondensedLine' : 'div'" :minScale="0.7" style="min-width: 0;">
<div style="display: flex; white-space: nowrap; align-items: baseline;">
<div v-if="mock" :class="$style.name">
<MkUserName :user="note.user"/>
</div>
<MkA v-else v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)">
<MkUserName :user="note.user"/>
</MkA>
<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
<div :class="$style.username"><MkAcct :user="note.user"/></div>
</div>
</component>
<div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
<img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
</div>
@ -40,6 +44,7 @@ import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js';
import { notePage } from '@/filters/note.js';
import { userPage } from '@/filters/user.js';
import { defaultStore } from '@/store.js';
defineProps<{
note: Misskey.entities.Note;

View file

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.root">
<MkAvatar :class="$style.avatar" :user="user" link preview/>
<MkAvatar :class="$style.avatar" :user="user"/>
<div :class="$style.main">
<div :class="$style.header">
<MkUserName :user="user" :nowrap="true"/>

View file

@ -7,13 +7,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.root">
<div :class="$style.head">
<MkAvatar v-if="['pollEnded', 'note'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/>
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" style="line-height: 1;"></i></div>
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
<img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/>
<MkAvatar v-else-if="'user' in notification" :class="$style.icon" :user="notification.user" link preview/>
<img v-else-if="'icon' in notification" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/>
<img v-else-if="'icon' in notification && notification.icon != null" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/>
<div
:class="[$style.subIcon, {
[$style.t_follow]: notification.type === 'follow',
@ -25,6 +25,8 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.t_quote]: notification.type === 'quote',
[$style.t_pollEnded]: notification.type === 'pollEnded',
[$style.t_achievementEarned]: notification.type === 'achievementEarned',
[$style.t_exportCompleted]: notification.type === 'exportCompleted',
[$style.t_login]: notification.type === 'login',
[$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null,
}]"
>
@ -37,6 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i>
<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
<i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i>
<i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i>
<i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i>
<template v-else-if="notification.type === 'roleAssigned'">
<img v-if="notification.role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="notification.role.iconUrl" alt=""/>
<i v-else class="ti ti-badges"></i>
@ -46,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:withTooltip="true"
:reaction="notification.reaction.replace(/^:(\w+):$/, ':$1@.:')"
:noStyle="true"
style="width: 100%; height: 100%;"
style="width: 100%; height: 100% !important; object-fit: contain;"
/>
</div>
</div>
@ -56,7 +60,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span>
<span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span>
<span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span>
<span v-else-if="notification.type === 'login'">{{ i18n.ts._notification.login }}</span>
<span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span>
<span v-else-if="notification.type === 'exportCompleted'">{{ i18n.tsx._notification.exportOfXCompleted({ x: exportEntityName[notification.exportedEntity] }) }}</span>
<MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<span v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'">{{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
<span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
@ -98,10 +104,20 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements">
{{ i18n.ts._achievements._types['_' + notification.achievement].title }}
</MkA>
<MkA v-else-if="notification.type === 'exportCompleted'" :class="$style.text" :to="`/my/drive/file/${notification.fileId}`">
{{ i18n.ts.showFile }}
</MkA>
<template v-else-if="notification.type === 'follow'">
<span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}</span>
</template>
<span v-else-if="notification.type === 'followRequestAccepted'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span>
<template v-else-if="notification.type === 'followRequestAccepted'">
<div :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</div>
<div v-if="notification.message" :class="$style.text" style="opacity: 0.6; font-style: oblique;">
<i class="ti ti-quote" :class="$style.quote"></i>
<span>{{ notification.message }}</span>
<i class="ti ti-quote" :class="$style.quote"></i>
</div>
</template>
<template v-else-if="notification.type === 'receiveFollowRequest'">
<span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.receiveFollowRequest }}</span>
<div v-if="full && !followRequestDone" :class="$style.followRequestCommands">
@ -122,7 +138,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:withTooltip="true"
:reaction="reaction.reaction.replace(/^:(\w+):$/, ':$1@.:')"
:noStyle="true"
style="width: 100%; height: 100%;"
style="width: 100%; height: 100% !important; object-fit: contain;"
/>
</div>
</div>
@ -161,6 +177,20 @@ const props = withDefaults(defineProps<{
full: false,
});
type ExportCompletedNotification = Misskey.entities.Notification & { type: 'exportCompleted' };
const exportEntityName = {
antenna: i18n.ts.antennas,
blocking: i18n.ts.blockedUsers,
clip: i18n.ts.clips,
customEmoji: i18n.ts.customEmojis,
favorite: i18n.ts.favorites,
following: i18n.ts.following,
muting: i18n.ts.mutedUsers,
note: i18n.ts.notes,
userList: i18n.ts.lists,
} as const satisfies Record<ExportCompletedNotification['exportedEntity'], string>;
const followRequestDone = ref(false);
const acceptFollowRequest = () => {
@ -190,6 +220,15 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
overflow-wrap: break-word;
display: flex;
contain: content;
--eventFollow: #36aed2;
--eventRenote: #36d298;
--eventReply: #007aff;
--eventReactionHeart: var(--love);
--eventReaction: #e99a0b;
--eventAchievement: #cb9a11;
--eventLogin: #007aff;
--eventOther: #88a6b7;
}
.head {
@ -298,12 +337,24 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
pointer-events: none;
}
.t_exportCompleted {
padding: 3px;
background: var(--eventOther);
pointer-events: none;
}
.t_roleAssigned {
padding: 3px;
background: var(--eventOther);
pointer-events: none;
}
.t_login {
padding: 3px;
background: var(--eventLogin);
pointer-events: none;
}
.tail {
flex: 1;
min-width: 0;
@ -343,7 +394,7 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
margin-right: 4px;
position: relative;
&:before {
&::before {
position: absolute;
transform: rotate(180deg);
}

View file

@ -35,7 +35,7 @@ import MkSwitch from './MkSwitch.vue';
import MkInfo from './MkInfo.vue';
import MkButton from './MkButton.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { notificationTypes } from '@/const.js';
import { notificationTypes } from '@@/js/const.js';
import { i18n } from '@/i18n.js';
type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>>

View file

@ -31,7 +31,7 @@ import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
import MkNote from '@/components/MkNote.vue';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
import { notificationTypes } from '@/const.js';
import { notificationTypes } from '@@/js/const.js';
import { infoImageUrl } from '@/instance.js';
import { defaultStore } from '@/store.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';

View file

@ -62,7 +62,7 @@ onUnmounted(() => {
left: 0;
width: 100%;
height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15));
background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
> .fadeLabel {
display: inline-block;

View file

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj" tabindex="-1">
<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj">
<div v-if="page.eyeCatchingImage" class="thumbnail">
<MediaImage
:image="page.eyeCatchingImage"
@ -50,12 +50,29 @@ const props = defineProps<{
<style lang="scss" scoped>
.vhpxefrj {
display: block;
position: relative;
&:hover {
text-decoration: none;
color: var(--accent);
}
&:focus-within {
outline: none;
&::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: var(--radius);
pointer-events: none;
box-shadow: inset 0 0 0 2px var(--focus);
}
}
> .thumbnail {
& + article {
border-radius: 0 0 var(--radius) var(--radius);

View file

@ -33,14 +33,14 @@ import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue'
import RouterView from '@/components/global/RouterView.vue';
import MkWindow from '@/components/MkWindow.vue';
import { popout as _popout } from '@/scripts/popout.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { url } from '@/config.js';
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { url } from '@@/js/config.js';
import { useScrollPositionManager } from '@/nirax.js';
import { i18n } from '@/i18n.js';
import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
import { openingWindowsCount } from '@/os.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { getScrollContainer } from '@/scripts/scroll.js';
import { getScrollContainer } from '@@/js/scroll.js';
import { useRouterFactory } from '@/router/supplier.js';
import { mainRouter } from '@/router/main.js';

View file

@ -45,10 +45,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts">
import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll.js';
import { useDocumentVisibility } from '@/scripts/use-document-visibility.js';
import { defaultStore } from '@/store.js';
import { MisskeyEntity } from '@/types/date-separated-list.js';
import { i18n } from '@/i18n.js';
@ -125,8 +125,6 @@ const items = ref<MisskeyEntityMap>(new Map());
*/
const queue = ref<MisskeyEntityMap>(new Map());
const offset = ref(0);
/**
* 初期化中かどうかtrueならMkLoadingで全て隠す
*/
@ -179,7 +177,9 @@ watch([backed, contentEl], () => {
if (!backed.value) {
if (!contentEl.value) return;
scrollRemove.value = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl.value, executeQueue, TOLERANCE);
scrollRemove.value = props.pagination.reversed
? onScrollBottom(contentEl.value, executeQueue, TOLERANCE)
: onScrollTop(contentEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE);
} else {
if (scrollRemove.value) scrollRemove.value();
scrollRemove.value = null;
@ -223,7 +223,6 @@ async function init(): Promise<void> {
more.value = true;
}
offset.value = res.length;
error.value = false;
fetching.value = false;
}, err => {
@ -244,7 +243,7 @@ const fetchMore = async (): Promise<void> => {
...params,
limit: SECOND_FETCH_LIMIT,
...(props.pagination.offsetMode ? {
offset: offset.value,
offset: items.value.size,
} : {
untilId: Array.from(items.value.keys()).at(-1),
}),
@ -294,7 +293,6 @@ const fetchMore = async (): Promise<void> => {
moreFetching.value = false;
}
}
offset.value += res.length;
}, err => {
moreFetching.value = false;
});
@ -308,7 +306,7 @@ const fetchMoreAhead = async (): Promise<void> => {
...params,
limit: SECOND_FETCH_LIMIT,
...(props.pagination.offsetMode ? {
offset: offset.value,
offset: items.value.size,
} : {
sinceId: Array.from(items.value.keys()).at(-1),
}),
@ -320,7 +318,6 @@ const fetchMoreAhead = async (): Promise<void> => {
items.value = concatMapWithArray(items.value, res);
more.value = true;
}
offset.value += res.length;
moreFetching.value = false;
}, err => {
moreFetching.value = false;

View file

@ -29,12 +29,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, ref } from 'vue';
import * as Misskey from 'misskey-js';
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
import { sum } from '@/scripts/array.js';
import { pleaseLogin } from '@/scripts/please-login.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { useInterval } from '@/scripts/use-interval.js';
import { host } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
const props = defineProps<{
noteId: string;
@ -60,6 +62,11 @@ const timer = computed(() => i18n.tsx._poll[
const showResult = ref(props.readOnly || isVoted.value);
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
type: 'lookup',
url: `https://${host}/notes/${props.noteId}`,
}));
//
if (props.poll.expiresAt) {
const tick = () => {
@ -76,10 +83,10 @@ if (props.poll.expiresAt) {
}
const vote = async (id) => {
pleaseLogin();
if (props.readOnly || closed.value || isVoted.value) return;
pleaseLogin(undefined, pleaseLoginContext.value);
const { canceled } = await os.confirm({
type: 'question',
text: i18n.tsx.voteConfirm({ choice: props.poll.choices[id].text }),
@ -138,7 +145,7 @@ const vote = async (id) => {
.done {
.choice {
cursor: default;
cursor: initial;
}
}
</style>

View file

@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
</section>
<section v-else-if="expiration === 'after'">
<MkInput v-model="after" small type="number" class="input">
<MkInput v-model="after" small type="number" min="1" class="input">
<template #label>{{ i18n.ts._poll.duration }}</template>
</MkInput>
<MkSelect v-model="unit" small>

View file

@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :src="src" :transparentBg="true" @click="click" @close="onModalClose" @closed="onModalClosed">
<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :class="{ [$style.drawer]: type === 'drawer' }" @close="onMenuClose" @hide="hide"/>
<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :src="src" :transparentBg="true" :returnFocusTo="returnFocusTo" @click="click" @close="onModalClose" @closed="onModalClosed">
<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :returnFocusTo="returnFocusTo" :class="{ [$style.drawer]: type === 'drawer' }" @close="onMenuClose" @hide="hide"/>
</MkModal>
</template>
@ -13,14 +13,14 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref, shallowRef } from 'vue';
import MkModal from './MkModal.vue';
import MkMenu from './MkMenu.vue';
import { MenuItem } from '@/types/menu.js';
import type { MenuItem } from '@/types/menu.js';
defineProps<{
items: MenuItem[];
align?: 'center' | string;
width?: number;
viaKeyboard?: boolean;
src?: any;
returnFocusTo?: HTMLElement | null;
}>();
const emit = defineEmits<{

View file

@ -105,11 +105,11 @@ import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor';
import { toASCII } from 'punycode/';
import { host, url } from '@@/js/config.js';
import MkNoteSimple from '@/components/MkNoteSimple.vue';
import MkNotePreview from '@/components/MkNotePreview.vue';
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
import MkPollEditor, { type PollEditorModelValue } from '@/components/MkPollEditor.vue';
import { host, url } from '@/config.js';
import { erase, unique } from '@/scripts/array.js';
import { extractMentions } from '@/scripts/extract-mentions.js';
import { formatTimeString } from '@/scripts/format-time-string.js';
@ -245,7 +245,7 @@ const submitText = computed((): string => {
});
const textLength = computed((): number => {
return (text.value + imeText.value).trim().length;
return (text.value + imeText.value).length;
});
const maxTextLength = computed((): number => {
@ -259,7 +259,7 @@ const canPost = computed((): boolean => {
1 <= files.value.length ||
poll.value != null ||
props.renote != null ||
(props.reply != null && quoteId.value != null)
quoteId.value != null
) &&
(textLength.value <= maxTextLength.value) &&
(!poll.value || poll.value.choices.length >= 2);
@ -367,6 +367,8 @@ function watchForDraft() {
watch(files, () => saveDraft(), { deep: true });
watch(visibility, () => saveDraft());
watch(localOnly, () => saveDraft());
watch(quoteId, () => saveDraft());
watch(reactionAcceptance, () => saveDraft());
}
function checkMissingMention() {
@ -570,6 +572,7 @@ function clear() {
function onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey) && canPost.value) post();
if (ev.key === 'Escape') emit('esc');
}
@ -702,6 +705,8 @@ function saveDraft() {
files: files.value,
poll: poll.value,
visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined,
quoteId: quoteId.value,
reactionAcceptance: reactionAcceptance.value,
},
};
@ -901,10 +906,23 @@ async function insertEmoji(ev: MouseEvent) {
textAreaReadOnly.value = true;
const target = ev.currentTarget ?? ev.target;
if (target == null) return;
// emojiPickertextarea
// focustrapinsertTextAtCursor
// 稿
// See: https://github.com/misskey-dev/misskey/pull/14282
// https://github.com/misskey-dev/misskey/issues/14274
let pos = textareaEl.value?.selectionStart ?? 0;
let posEnd = textareaEl.value?.selectionEnd ?? text.value.length;
emojiPicker.show(
target as HTMLElement,
emoji => {
insertTextAtCursor(textareaEl.value, emoji);
const textBefore = text.value.substring(0, pos);
const textAfter = text.value.substring(posEnd);
text.value = textBefore + emoji + textAfter;
pos += emoji.length;
posEnd += emoji.length;
},
() => {
textAreaReadOnly.value = false;
@ -990,6 +1008,8 @@ onMounted(() => {
users.forEach(u => pushVisibleUser(u));
});
}
quoteId.value = draft.data.quoteId;
reactionAcceptance.value = draft.data.reactionAcceptance;
}
}
@ -997,9 +1017,11 @@ onMounted(() => {
if (props.initialNote) {
const init = props.initialNote;
text.value = init.text ? init.text : '';
files.value = init.files ?? [];
cw.value = init.cw ?? null;
useCw.value = init.cw != null;
cw.value = init.cw ?? null;
visibility.value = init.visibility;
localOnly.value = init.localOnly ?? false;
files.value = init.files ?? [];
if (init.poll) {
poll.value = {
choices: init.poll.choices.map(x => x.text),
@ -1008,9 +1030,13 @@ onMounted(() => {
expiredAfter: null,
};
}
visibility.value = init.visibility;
localOnly.value = init.localOnly ?? false;
if (init.visibleUserIds) {
misskeyApi('users/show', { userIds: init.visibleUserIds }).then(users => {
users.forEach(u => pushVisibleUser(u));
});
}
quoteId.value = init.renote ? init.renote.id : null;
reactionAcceptance.value = init.reactionAcceptance;
}
nextTick(() => watchForDraft());
@ -1083,6 +1109,15 @@ defineExpose({
margin: 12px 12px 12px 6px;
vertical-align: bottom;
&:focus-visible {
outline: none;
.submitInner {
outline: 2px solid var(--fgOnAccent);
outline-offset: -4px;
}
}
&:disabled {
opacity: 0.7;
}
@ -1093,13 +1128,13 @@ defineExpose({
&:not(:disabled):hover {
> .inner {
background: linear-gradient(90deg, var(--X8), var(--X8));
background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
}
}
&:not(:disabled):active {
> .inner {
background: linear-gradient(90deg, var(--X8), var(--X8));
background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
}
}
}
@ -1166,6 +1201,15 @@ defineExpose({
min-height: 75px;
max-height: 150px;
overflow: auto;
background-size: auto auto;
}
html[data-color-scheme=dark] .preview {
background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, #0004 5px, #0004 10px);
}
html[data-color-scheme=light] .preview {
background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, #00000005 5px, #00000005 10px);
}
.targetNote {

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