diff options
| author | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-03-09 14:28:01 +0900 |
|---|---|---|
| committer | syuilo <4439005+syuilo@users.noreply.github.com> | 2025-03-09 14:28:01 +0900 |
| commit | be7e3b9a0cb81b78a744993fef2fa2fd2833fa9c (patch) | |
| tree | c82e18ce93ec0a24c57d7e36eb54a09266b3a25b /packages/frontend/src/utility | |
| parent | enhnace(frontend): 文字列比較のためのローマナイズを強化(... (diff) | |
| download | sharkey-be7e3b9a0cb81b78a744993fef2fa2fd2833fa9c.tar.gz sharkey-be7e3b9a0cb81b78a744993fef2fa2fd2833fa9c.tar.bz2 sharkey-be7e3b9a0cb81b78a744993fef2fa2fd2833fa9c.zip | |
refactor(frontend): scripts -> utility
Diffstat (limited to 'packages/frontend/src/utility')
92 files changed, 9158 insertions, 0 deletions
diff --git a/packages/frontend/src/utility/achievements.ts b/packages/frontend/src/utility/achievements.ts new file mode 100644 index 0000000000..3025a985ba --- /dev/null +++ b/packages/frontend/src/utility/achievements.ts @@ -0,0 +1,513 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { misskeyApi } from '@/utility/misskey-api.js'; +import { $i } from '@/account.js'; + +export const ACHIEVEMENT_TYPES = [ + 'notes1', + 'notes10', + 'notes100', + 'notes500', + 'notes1000', + 'notes5000', + 'notes10000', + 'notes20000', + 'notes30000', + 'notes40000', + 'notes50000', + 'notes60000', + 'notes70000', + 'notes80000', + 'notes90000', + 'notes100000', + 'login3', + 'login7', + 'login15', + 'login30', + 'login60', + 'login100', + 'login200', + 'login300', + 'login400', + 'login500', + 'login600', + 'login700', + 'login800', + 'login900', + 'login1000', + 'passedSinceAccountCreated1', + 'passedSinceAccountCreated2', + 'passedSinceAccountCreated3', + 'loggedInOnBirthday', + 'loggedInOnNewYearsDay', + 'noteClipped1', + 'noteFavorited1', + 'myNoteFavorited1', + 'profileFilled', + 'markedAsCat', + 'following1', + 'following10', + 'following50', + 'following100', + 'following300', + 'followers1', + 'followers10', + 'followers50', + 'followers100', + 'followers300', + 'followers500', + 'followers1000', + 'collectAchievements30', + 'viewAchievements3min', + 'iLoveMisskey', + 'foundTreasure', + 'client30min', + 'client60min', + 'noteDeletedWithin1min', + 'postedAtLateNight', + 'postedAt0min0sec', + 'selfQuote', + 'htl20npm', + 'viewInstanceChart', + 'outputHelloWorldOnScratchpad', + 'open3windows', + 'driveFolderCircularReference', + 'reactWithoutRead', + 'clickedClickHere', + 'justPlainLucky', + 'setNameToSyuilo', + 'cookieClicked', + 'brainDiver', + 'smashTestNotificationButton', + 'tutorialCompleted', + 'bubbleGameExplodingHead', + 'bubbleGameDoubleExplodingHead', +] as const; + +export const ACHIEVEMENT_BADGES = { + 'notes1': { + img: '/fluent-emoji/1f4dd.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'notes10': { + img: '/fluent-emoji/1f4d1.png', + bg: null, + frame: 'bronze', + }, + 'notes100': { + img: '/fluent-emoji/1f4d2.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'notes500': { + img: '/fluent-emoji/1f4da.png', + bg: null, + frame: 'bronze', + }, + 'notes1000': { + img: '/fluent-emoji/1f5c3.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'notes5000': { + img: '/fluent-emoji/1f304.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'notes10000': { + img: '/fluent-emoji/1f3d9.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'silver', + }, + 'notes20000': { + img: '/fluent-emoji/1f307.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'silver', + }, + 'notes30000': { + img: '/fluent-emoji/1f306.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'silver', + }, + 'notes40000': { + img: '/fluent-emoji/1f303.png', + bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))', + frame: 'silver', + }, + 'notes50000': { + img: '/fluent-emoji/1fa90.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'gold', + }, + 'notes60000': { + img: '/fluent-emoji/2604.png', + bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))', + frame: 'gold', + }, + 'notes70000': { + img: '/fluent-emoji/1f30c.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'gold', + }, + 'notes80000': { + img: '/fluent-emoji/1f30c.png', + bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))', + frame: 'gold', + }, + 'notes90000': { + img: '/fluent-emoji/1f30c.png', + bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))', + frame: 'gold', + }, + 'notes100000': { + img: '/fluent-emoji/267e.png', + bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))', + frame: 'platinum', + }, + 'login3': { + img: '/fluent-emoji/1f331.png', + bg: null, + frame: 'bronze', + }, + 'login7': { + img: '/fluent-emoji/1f331.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'login15': { + img: '/fluent-emoji/1f331.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'bronze', + }, + 'login30': { + img: '/fluent-emoji/1fab4.png', + bg: null, + frame: 'bronze', + }, + 'login60': { + img: '/fluent-emoji/1fab4.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'login100': { + img: '/fluent-emoji/1fab4.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'silver', + }, + 'login200': { + img: '/fluent-emoji/1f333.png', + bg: null, + frame: 'silver', + }, + 'login300': { + img: '/fluent-emoji/1f333.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'silver', + }, + 'login400': { + img: '/fluent-emoji/1f333.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'silver', + }, + 'login500': { + img: '/fluent-emoji/1f304.png', + bg: null, + frame: 'silver', + }, + 'login600': { + img: '/fluent-emoji/1f304.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'gold', + }, + 'login700': { + img: '/fluent-emoji/1f304.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'gold', + }, + 'login800': { + img: '/fluent-emoji/1f307.png', + bg: null, + frame: 'gold', + }, + 'login900': { + img: '/fluent-emoji/1f307.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'gold', + }, + 'login1000': { + img: '/fluent-emoji/1f307.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'platinum', + }, + 'noteClipped1': { + img: '/fluent-emoji/1f587.png', + bg: null, + frame: 'bronze', + }, + 'noteFavorited1': { + img: '/fluent-emoji/1f31f.png', + bg: null, + frame: 'bronze', + }, + 'myNoteFavorited1': { + img: '/fluent-emoji/1f320.png', + bg: null, + frame: 'silver', + }, + 'profileFilled': { + img: '/fluent-emoji/1f44c.png', + bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', + frame: 'bronze', + }, + 'markedAsCat': { + img: '/fluent-emoji/1f408.png', + bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', + frame: 'bronze', + }, + 'following1': { + img: '/fluent-emoji/2618.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'following10': { + img: '/fluent-emoji/1f6b8.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'following50': { + img: '/fluent-emoji/1f91d.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'following100': { + img: '/fluent-emoji/1f4af.png', + bg: 'linear-gradient(0deg, rgb(255 53 184), rgb(255 206 69))', + frame: 'silver', + }, + 'following300': { + img: '/fluent-emoji/1f970.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'silver', + }, + 'followers1': { + img: '/fluent-emoji/2618.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'followers10': { + img: '/fluent-emoji/1f44b.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'followers50': { + img: '/fluent-emoji/1f411.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'bronze', + }, + 'followers100': { + img: '/fluent-emoji/1f60e.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'silver', + }, + 'followers300': { + img: '/fluent-emoji/1f3c6.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'silver', + }, + 'followers500': { + img: '/fluent-emoji/1f4e1.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'gold', + }, + 'followers1000': { + img: '/fluent-emoji/1f451.png', + bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))', + frame: 'platinum', + }, + 'collectAchievements30': { + img: '/fluent-emoji/1f3c5.png', + bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))', + frame: 'silver', + }, + 'viewAchievements3min': { + img: '/fluent-emoji/1f3c5.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'bronze', + }, + 'iLoveMisskey': { + img: '/fluent-emoji/2764.png', + bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))', + frame: 'silver', + }, + 'foundTreasure': { + img: '/fluent-emoji/1f3c6.png', + bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))', + frame: 'gold', + }, + 'client30min': { + img: '/fluent-emoji/1f552.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'bronze', + }, + 'client60min': { + img: '/fluent-emoji/1f552.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'silver', + }, + 'noteDeletedWithin1min': { + img: '/fluent-emoji/1f5d1.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'bronze', + }, + 'postedAtLateNight': { + img: '/fluent-emoji/1f319.png', + bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))', + frame: 'bronze', + }, + 'postedAt0min0sec': { + img: '/fluent-emoji/1f55b.png', + bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))', + frame: 'bronze', + }, + 'selfQuote': { + img: '/fluent-emoji/1f4dd.png', + bg: null, + frame: 'bronze', + }, + 'htl20npm': { + img: '/fluent-emoji/1f30a.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'bronze', + }, + 'viewInstanceChart': { + img: '/fluent-emoji/1f4ca.png', + bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))', + frame: 'bronze', + }, + 'outputHelloWorldOnScratchpad': { + img: '/fluent-emoji/1f530.png', + bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))', + frame: 'bronze', + }, + 'open3windows': { + img: '/fluent-emoji/1f5a5.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'bronze', + }, + 'driveFolderCircularReference': { + img: '/fluent-emoji/1f4c2.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'bronze', + }, + 'reactWithoutRead': { + img: '/fluent-emoji/2753.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'bronze', + }, + 'clickedClickHere': { + img: '/fluent-emoji/2757.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'bronze', + }, + 'justPlainLucky': { + img: '/fluent-emoji/1f340.png', + bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', + frame: 'silver', + }, + 'setNameToSyuilo': { + img: '/fluent-emoji/1f36e.png', + bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', + frame: 'bronze', + }, + 'passedSinceAccountCreated1': { + img: '/fluent-emoji/0031-20e3.png', + bg: null, + frame: 'bronze', + }, + 'passedSinceAccountCreated2': { + img: '/fluent-emoji/0032-20e3.png', + bg: null, + frame: 'silver', + }, + 'passedSinceAccountCreated3': { + img: '/fluent-emoji/0033-20e3.png', + bg: null, + frame: 'gold', + }, + 'loggedInOnBirthday': { + img: '/fluent-emoji/1f382.png', + bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))', + frame: 'silver', + }, + 'loggedInOnNewYearsDay': { + img: '/fluent-emoji/1f38d.png', + bg: 'linear-gradient(0deg, rgb(255 144 144), rgb(255 232 168))', + frame: 'silver', + }, + 'cookieClicked': { + img: '/fluent-emoji/1f36a.png', + bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', + frame: 'bronze', + }, + 'brainDiver': { + img: '/fluent-emoji/1f9e0.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'bronze', + }, + 'smashTestNotificationButton': { + img: '/fluent-emoji/1f514.png', + bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', + frame: 'bronze', + }, + 'tutorialCompleted': { + img: '/fluent-emoji/1f393.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'bronze', + }, + 'bubbleGameExplodingHead': { + img: '/fluent-emoji/1f92f.png', + bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))', + frame: 'bronze', + }, + 'bubbleGameDoubleExplodingHead': { + img: '/fluent-emoji/1f92f.png', + bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))', + frame: 'silver', + }, +/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107> +} as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], { + img: string; + bg: string | null; + frame: 'bronze' | 'silver' | 'gold' | 'platinum'; +}>; + */ +} as const; + +export const claimedAchievements: typeof ACHIEVEMENT_TYPES[number][] = ($i && $i.achievements) ? $i.achievements.map(x => x.name) : []; + +const claimingQueue = new Set<string>(); + +export async function claimAchievement(type: typeof ACHIEVEMENT_TYPES[number]) { + if ($i == null) return; + if ($i.movedTo) return; + if (claimedAchievements.includes(type)) return; + claimingQueue.add(type); + claimedAchievements.push(type); + await new Promise(resolve => setTimeout(resolve, (claimingQueue.size - 1) * 500)); + window.setTimeout(() => { + claimingQueue.delete(type); + }, 500); + misskeyApi('i/claim-achievement', { name: type }); +} + +if (_DEV_) { + (window as any).unlockAllAchievements = () => { + for (const t of ACHIEVEMENT_TYPES) { + claimAchievement(t); + } + }; +} diff --git a/packages/frontend/src/utility/admin-lookup.ts b/packages/frontend/src/utility/admin-lookup.ts new file mode 100644 index 0000000000..7405e229fe --- /dev/null +++ b/packages/frontend/src/utility/admin-lookup.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; + +export async function lookupUser() { + const { canceled, result } = await os.inputText({ + title: i18n.ts.usernameOrUserId, + }); + if (canceled) return; + + const show = (user) => { + os.pageWindow(`/admin/user/${user.id}`); + }; + + const usernamePromise = misskeyApi('users/show', Misskey.acct.parse(result)); + const idPromise = misskeyApi('users/show', { userId: result }); + let _notFound = false; + const notFound = () => { + if (_notFound) { + os.alert({ + type: 'error', + text: i18n.ts.noSuchUser, + }); + } else { + _notFound = true; + } + }; + usernamePromise.then(show).catch(err => { + if (err.code === 'NO_SUCH_USER') { + notFound(); + } + }); + idPromise.then(show).catch(err => { + notFound(); + }); +} + +export async function lookupUserByEmail() { + const { canceled, result } = await os.inputText({ + title: i18n.ts.emailAddress, + type: 'email', + }); + if (canceled) return; + + try { + const user = await os.apiWithDialog('admin/accounts/find-by-email', { email: result }); + + os.pageWindow(`/admin/user/${user.id}`); + } catch (err) { + if (err.code === 'USER_NOT_FOUND') { + os.alert({ + type: 'error', + text: i18n.ts.noSuchUser, + }); + } else { + throw err; + } + } +} + +export async function lookupFile() { + const { canceled, result: q } = await os.inputText({ + title: i18n.ts.fileIdOrUrl, + minLength: 1, + }); + if (canceled) return; + + const show = (file) => { + os.pageWindow(`/admin/file/${file.id}`); + }; + + misskeyApi('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => { + show(file); + }).catch(err => { + if (err.code === 'NO_SUCH_FILE') { + os.alert({ + type: 'error', + text: i18n.ts.notFound, + }); + } + }); +} diff --git a/packages/frontend/src/utility/array.ts b/packages/frontend/src/utility/array.ts new file mode 100644 index 0000000000..f2feb29dfc --- /dev/null +++ b/packages/frontend/src/utility/array.ts @@ -0,0 +1,118 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +type EndoRelation<T> = (a: T, b: T) => boolean; +type Predicate<T> = (x: T) => boolean; + +/** + * Count the number of elements that satisfy the predicate + */ + +export function countIf<T>(f: Predicate<T>, xs: T[]): number { + return xs.filter(f).length; +} + +/** + * Count the number of elements that is equal to the element + */ +export function count<T>(a: T, xs: T[]): number { + return countIf(x => x === a, xs); +} + +/** + * Concatenate an array of arrays + */ +export function concat<T>(xss: T[][]): T[] { + return ([] as T[]).concat(...xss); +} + +/** + * Intersperse the element between the elements of the array + * @param sep The element to be interspersed + */ +export function intersperse<T>(sep: T, xs: T[]): T[] { + return concat(xs.map(x => [sep, x])).slice(1); +} + +/** + * Returns the array of elements that is not equal to the element + */ +export function erase<T>(a: T, xs: T[]): T[] { + return xs.filter(x => x !== a); +} + +/** + * Finds the array of all elements in the first array not contained in the second array. + * The order of result values are determined by the first array. + */ +export function difference<T>(xs: T[], ys: T[]): T[] { + return xs.filter(x => !ys.includes(x)); +} + +/** + * Remove all but the first element from every group of equivalent elements + */ +export function unique<T>(xs: T[]): T[] { + return [...new Set(xs)]; +} + +export function uniqueBy<TValue, TKey>(values: TValue[], keySelector: (value: TValue) => TKey): TValue[] { + const map = new Map<TKey, TValue>(); + + for (const value of values) { + const key = keySelector(value); + if (!map.has(key)) map.set(key, value); + } + + return [...map.values()]; +} + +export function sum(xs: number[]): number { + return xs.reduce((a, b) => a + b, 0); +} + +export function maximum(xs: number[]): number { + return Math.max(...xs); +} + +/** + * Compare two arrays by lexicographical order + */ +export function lessThan(xs: number[], ys: number[]): boolean { + for (let i = 0; i < Math.min(xs.length, ys.length); i++) { + if (xs[i] < ys[i]) return true; + if (xs[i] > ys[i]) return false; + } + return xs.length < ys.length; +} + +/** + * Returns the longest prefix of elements that satisfy the predicate + */ +export function takeWhile<T>(f: Predicate<T>, xs: T[]): T[] { + const ys: T[] = []; + for (const x of xs) { + if (f(x)) { + ys.push(x); + } else { + break; + } + } + return ys; +} + +export function cumulativeSum(xs: number[]): number[] { + const ys = Array.from(xs); // deep copy + for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1]; + return ys; +} + +export function toArray<T>(x: T | T[] | undefined): T[] { + return Array.isArray(x) ? x : x != null ? [x] : []; +} + +export function toSingle<T>(x: T | T[] | undefined): T | undefined { + return Array.isArray(x) ? x[0] : x; +} diff --git a/packages/frontend/src/utility/autocomplete.ts b/packages/frontend/src/utility/autocomplete.ts new file mode 100644 index 0000000000..9a603b848c --- /dev/null +++ b/packages/frontend/src/utility/autocomplete.ts @@ -0,0 +1,323 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { nextTick, ref, defineAsyncComponent } from 'vue'; +import getCaretCoordinates from 'textarea-caret'; +import { toASCII } from 'punycode.js'; +import type { Ref } from 'vue'; +import { popup } from '@/os.js'; + +export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam'; + +export class Autocomplete { + private suggestion: { + x: Ref<number>; + y: Ref<number>; + q: Ref<any>; + close: () => void; + } | null; + private textarea: HTMLInputElement | HTMLTextAreaElement; + private currentType: string; + private textRef: Ref<string | number | null>; + private opening: boolean; + private onlyType: SuggestionType[]; + + private get text(): string { + // Use raw .value to get the latest value + // (Because v-model does not update while composition) + return this.textarea.value; + } + + private set text(text: string) { + // Use ref value to notify other watchers + // (Because .value setter never fires input/change events) + this.textRef.value = text; + } + + /** + * 対象のテキストエリアを与えてインスタンスを初期化します。 + */ + constructor(textarea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string | number | null>, onlyType?: SuggestionType[]) { + //#region BIND + this.onInput = this.onInput.bind(this); + this.complete = this.complete.bind(this); + this.close = this.close.bind(this); + //#endregion + + this.suggestion = null; + this.textarea = textarea; + this.textRef = textRef; + this.opening = false; + this.onlyType = onlyType ?? ['user', 'hashtag', 'emoji', 'mfmTag', 'mfmParam']; + + this.attach(); + } + + /** + * このインスタンスにあるテキストエリアの入力のキャプチャを開始します。 + */ + public attach() { + this.textarea.addEventListener('input', this.onInput); + } + + /** + * このインスタンスにあるテキストエリアの入力のキャプチャを解除します。 + */ + public detach() { + this.textarea.removeEventListener('input', this.onInput); + this.close(); + } + + /** + * テキスト入力時 + */ + private onInput() { + const caretPos = this.textarea.selectionStart; + const text = this.text.substring(0, caretPos).split('\n').pop()!; + + const mentionIndex = text.lastIndexOf('@'); + const hashtagIndex = text.lastIndexOf('#'); + const emojiIndex = text.lastIndexOf(':'); + const mfmTagIndex = text.lastIndexOf('$'); + const mfmParamIndex = text.lastIndexOf('.'); + + const max = Math.max( + mentionIndex, + hashtagIndex, + emojiIndex, + mfmTagIndex); + + if (max === -1) { + this.close(); + return; + } + + const afterLastMfmParam = text.split(/\$\[[a-zA-Z]+/).pop(); + + const isMention = mentionIndex !== -1; + const isHashtag = hashtagIndex !== -1; + const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' '); + const isMfmTag = mfmTagIndex !== -1 && !isMfmParam; + const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':'); + + let opened = false; + + if (isMention && this.onlyType.includes('user')) { + // ユーザのサジェスト中に@を入力すると、その位置から新たにユーザ名を取りなおそうとしてしまう + // この動きはリモートユーザのサジェストを阻害するので、@を検知したらその位置よりも前の@を探し、 + // ホスト名を含むリモートのユーザ名を全て拾えるようにする + const mentionIndexAlt = text.lastIndexOf('@', mentionIndex - 1); + const username = mentionIndexAlt === -1 + ? text.substring(mentionIndex + 1) + : text.substring(mentionIndexAlt + 1); + if (username !== '' && username.match(/^[a-zA-Z0-9_@.]+$/)) { + this.open('user', username); + opened = true; + } else if (username === '') { + this.open('user', null); + opened = true; + } + } + + if (isHashtag && !opened && this.onlyType.includes('hashtag')) { + const hashtag = text.substring(hashtagIndex + 1); + if (!hashtag.includes(' ')) { + this.open('hashtag', hashtag); + opened = true; + } + } + + if (isEmoji && !opened && this.onlyType.includes('emoji')) { + const emoji = text.substring(emojiIndex + 1); + if (!emoji.includes(' ')) { + this.open('emoji', emoji); + opened = true; + } + } + + if (isMfmTag && !opened && this.onlyType.includes('mfmTag')) { + const mfmTag = text.substring(mfmTagIndex + 1); + if (!mfmTag.includes(' ')) { + this.open('mfmTag', mfmTag.replace('[', '')); + opened = true; + } + } + + if (isMfmParam && !opened && this.onlyType.includes('mfmParam')) { + const mfmParam = text.substring(mfmParamIndex + 1); + if (!mfmParam.includes(' ')) { + this.open('mfmParam', { + tag: text.substring(mfmTagIndex + 2, mfmParamIndex), + params: mfmParam.split(','), + }); + opened = true; + } + } + + if (!opened) { + this.close(); + } + } + + /** + * サジェストを提示します。 + */ + private async open(type: string, q: any) { + if (type !== this.currentType) { + this.close(); + } + if (this.opening) return; + this.opening = true; + this.currentType = type; + + //#region サジェストを表示すべき位置を計算 + const caretPosition = getCaretCoordinates(this.textarea, this.textarea.selectionStart); + + const rect = this.textarea.getBoundingClientRect(); + + const x = rect.left + caretPosition.left - this.textarea.scrollLeft; + const y = rect.top + caretPosition.top - this.textarea.scrollTop; + //#endregion + + if (this.suggestion) { + this.suggestion.x.value = x; + this.suggestion.y.value = y; + this.suggestion.q.value = q; + + this.opening = false; + } else { + const _x = ref(x); + const _y = ref(y); + const _q = ref(q); + + const { dispose } = await popup(defineAsyncComponent(() => import('@/components/MkAutocomplete.vue')), { + textarea: this.textarea, + close: this.close, + type: type, + q: _q, + x: _x, + y: _y, + }, { + done: (res) => { + this.complete(res); + }, + }); + + this.suggestion = { + q: _q, + x: _x, + y: _y, + close: () => dispose(), + }; + + this.opening = false; + } + } + + /** + * サジェストを閉じます。 + */ + private close() { + if (this.suggestion == null) return; + + this.suggestion.close(); + this.suggestion = null; + + this.textarea.focus(); + } + + /** + * オートコンプリートする + */ + private complete({ type, value }) { + this.close(); + + const caret = this.textarea.selectionStart; + + if (type === 'user') { + const source = this.text; + + const before = source.substring(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('@')); + const after = source.substring(caret); + + const acct = value.host === null ? value.username : `${value.username}@${toASCII(value.host)}`; + + // 挿入 + this.text = `${trimmedBefore}@${acct} ${after}`; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (acct.length + 2); + this.textarea.setSelectionRange(pos, pos); + }); + } else if (type === 'hashtag') { + const source = this.text; + + const before = source.substring(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('#')); + const after = source.substring(caret); + + // 挿入 + this.text = `${trimmedBefore}#${value} ${after}`; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (value.length + 2); + this.textarea.setSelectionRange(pos, pos); + }); + } else if (type === 'emoji') { + const source = this.text; + + const before = source.substring(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf(':')); + const after = source.substring(caret); + + // 挿入 + this.text = trimmedBefore + value + after; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + value.length; + this.textarea.setSelectionRange(pos, pos); + }); + } else if (type === 'mfmTag') { + const source = this.text; + + const before = source.substring(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('$')); + const after = source.substring(caret); + + // 挿入 + this.text = `${trimmedBefore}$[${value} ]${after}`; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (value.length + 3); + this.textarea.setSelectionRange(pos, pos); + }); + } else if (type === 'mfmParam') { + const source = this.text; + + const before = source.substring(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('.')); + const after = source.substring(caret); + + // 挿入 + this.text = `${trimmedBefore}.${value}${after}`; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (value.length + 1); + this.textarea.setSelectionRange(pos, pos); + }); + } + } +} diff --git a/packages/frontend/src/utility/autogen/settings-search-index.ts b/packages/frontend/src/utility/autogen/settings-search-index.ts new file mode 100644 index 0000000000..8e2a896b17 --- /dev/null +++ b/packages/frontend/src/utility/autogen/settings-search-index.ts @@ -0,0 +1,822 @@ + +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// This file was automatically generated by create-search-index. +// Do not edit this file. + +import { i18n } from '@/i18n.js'; + +export type SearchIndexItem = { + id: string; + path?: string; + label: string; + keywords: string[]; + icon?: string; + children?: SearchIndexItem[]; +}; + +export const searchIndexes: SearchIndexItem[] = [ + { + id: 'flXd1LC7r', + children: [ + { + id: 'hB11H5oul', + label: i18n.ts.syncDeviceDarkMode, + keywords: ['sync', 'device', 'dark', 'light', 'mode'], + }, + { + id: 'fDbLtIKeo', + label: i18n.ts.themeForLightMode, + keywords: ['light', 'theme'], + }, + { + id: 'eLOwK5Ia2', + label: i18n.ts.themeForDarkMode, + keywords: ['dark', 'theme'], + }, + { + id: 'ujvMfyzUr', + label: i18n.ts.setWallpaper, + keywords: ['wallpaper'], + }, + ], + label: i18n.ts.theme, + keywords: ['theme'], + path: '/settings/theme', + icon: 'ti ti-palette', + }, + { + id: '6fFIRXUww', + children: [ + { + id: 'nO7NnzqiC', + label: i18n.ts.notUseSound, + keywords: ['mute'], + }, + { + id: 'oALW4ja7U', + label: i18n.ts.useSoundOnlyWhenActive, + keywords: ['active', 'mute'], + }, + { + id: 'BbJK2SKT2', + label: i18n.ts.masterVolume, + keywords: ['volume', 'master'], + }, + ], + label: i18n.ts.sounds, + keywords: ['sounds'], + path: '/settings/sounds', + icon: 'ti ti-music', + }, + { + id: '5BjnxMfYV', + children: [ + { + id: '3UqdSCaFw', + children: [ + { + id: '75QPEg57v', + label: i18n.ts.changePassword, + keywords: [], + }, + ], + label: i18n.ts.password, + keywords: ['password'], + }, + { + id: '2fa', + children: [ + { + id: 'qCXM0HtJ7', + label: i18n.ts.totp, + keywords: ['totp', 'app', i18n.ts.totpDescription], + }, + { + id: '3g1RePuD9', + label: i18n.ts.securityKeyAndPasskey, + keywords: ['security', 'key', 'passkey'], + }, + { + id: 'pFRud5u8k', + label: i18n.ts.passwordLessLogin, + keywords: ['password', 'less', 'key', 'passkey', 'login', 'signin', i18n.ts.passwordLessLoginDescription], + }, + ], + label: i18n.ts['2fa'], + keywords: ['2fa'], + }, + ], + label: i18n.ts.security, + keywords: ['security'], + path: '/settings/security', + icon: 'ti ti-lock', + }, + { + id: 'w4L6myH61', + children: [ + { + id: 'ru8DrOn3J', + label: i18n.ts._profile.changeBanner, + keywords: ['banner', 'change'], + }, + { + id: 'CCnD8Apnu', + label: i18n.ts._profile.changeAvatar, + keywords: ['avatar', 'icon', 'change'], + }, + { + id: 'yFEVCJxFX', + label: i18n.ts._profile.name, + keywords: ['name'], + }, + { + id: '2O1S5reaB', + label: i18n.ts._profile.description, + keywords: ['description', 'bio'], + }, + { + id: 'pWi4OLS8g', + label: i18n.ts.location, + keywords: ['location', 'locale'], + }, + { + id: 'oLO5X6Wtw', + label: i18n.ts.birthday, + keywords: ['birthday', 'birthdate', 'age'], + }, + { + id: 'm2trKwPgq', + label: i18n.ts.language, + keywords: ['language', 'locale'], + }, + { + id: 'kfDZxCDp9', + label: i18n.ts._profile.metadataEdit, + keywords: ['metadata'], + }, + { + id: 'uPt3MFymp', + label: i18n.ts._profile.followedMessage, + keywords: ['follow', 'message', i18n.ts._profile.followedMessageDescription], + }, + { + id: 'wuGg0tBjw', + label: i18n.ts.reactionAcceptance, + keywords: ['reaction'], + }, + { + id: 'EezPpmMnf', + children: [ + { + id: 'f2cRLh8ad', + label: i18n.ts.flagAsCat, + keywords: ['cat'], + }, + { + id: 'eVoViiF3h', + label: i18n.ts.flagAsBot, + keywords: ['bot'], + }, + ], + label: i18n.ts.advancedSettings, + keywords: [], + }, + ], + label: i18n.ts.profile, + keywords: ['profile'], + path: '/settings/profile', + icon: 'ti ti-user', + }, + { + id: '2rp9ka5Ht', + children: [ + { + id: 'qBUSKPxLW', + label: i18n.ts.makeFollowManuallyApprove, + keywords: ['follow', 'lock', i18n.ts.lockedAccountInfo], + }, + { + id: '3LZBlZCej', + label: i18n.ts.autoAcceptFollowed, + keywords: ['follow', 'auto', 'accept'], + }, + { + id: '9gOp28wKG', + label: i18n.ts.makeReactionsPublic, + keywords: ['reaction', 'public', i18n.ts.makeReactionsPublicDescription], + }, + { + id: 'CjAkqMhct', + label: i18n.ts.followingVisibility, + keywords: ['following', 'visibility'], + }, + { + id: '4nEwI6LYt', + label: i18n.ts.followersVisibility, + keywords: ['follower', 'visibility'], + }, + { + id: 'naMp37wTL', + label: i18n.ts.hideOnlineStatus, + keywords: ['online', 'status', i18n.ts.hideOnlineStatusDescription], + }, + { + id: 'p0dCVR0UP', + label: i18n.ts.noCrawle, + keywords: ['crawle', 'index', 'search', i18n.ts.noCrawleDescription], + }, + { + id: 'aceURmNPq', + label: i18n.ts.preventAiLearning, + keywords: ['crawle', 'ai', i18n.ts.preventAiLearningDescription], + }, + { + id: 'ahABA0j7u', + label: i18n.ts.makeExplorable, + keywords: ['explore', i18n.ts.makeExplorableDescription], + }, + { + id: 'cyeDbLN8N', + children: [ + { + id: 'xEYlOghao', + label: i18n.ts._accountSettings.requireSigninToViewContents, + keywords: ['login', 'signin'], + }, + { + id: 'sMmYFCS60', + label: i18n.ts._accountSettings.makeNotesFollowersOnlyBefore, + keywords: ['follower', i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription], + }, + { + id: '2prkeWRSd', + label: i18n.ts._accountSettings.makeNotesHiddenBefore, + keywords: ['hidden', i18n.ts._accountSettings.makeNotesHiddenBeforeDescription], + }, + ], + label: i18n.ts.lockdown, + keywords: ['lockdown'], + }, + ], + label: i18n.ts.privacy, + keywords: ['privacy'], + path: '/settings/privacy', + icon: 'ti ti-lock-open', + }, + { + id: '3yCAv0IsZ', + children: [ + { + id: 'x1GWSQnPw', + label: i18n.ts.uiLanguage, + keywords: ['language'], + }, + { + id: 'EOSa4rtt3', + label: i18n.ts.overridedDeviceKind, + keywords: ['device', 'type', 'kind', 'smartphone', 'tablet', 'desktop'], + }, + { + id: 'm9LhX8BG8', + label: i18n.ts.showFixedPostForm, + keywords: ['post', 'form', 'timeline'], + }, + { + id: 'snyCQ5oKE', + label: i18n.ts.showFixedPostFormInChannel, + keywords: ['post', 'form', 'timeline', 'channel'], + }, + { + id: '8j36S4Ev6', + label: i18n.ts.pinnedList, + keywords: ['pinned', 'list'], + }, + { + id: 'CWpyT9vLK', + label: i18n.ts.enableQuickAddMfmFunction, + keywords: ['mfm', 'enable', 'show', 'advanced', 'picker', 'form', 'function', 'fn'], + }, + { + id: '1yhown1Xc', + label: i18n.ts.rememberNoteVisibility, + keywords: ['remember', 'keep', 'note', 'visibility'], + }, + { + id: 'wUeAI5QBV', + label: i18n.ts.defaultNoteVisibility, + keywords: ['default', 'note', 'visibility'], + }, + { + id: '6kMj4HVOg', + children: [ + { + id: 'DQIcvf64G', + label: i18n.ts.collapseRenotes, + keywords: ['renote', i18n.ts.collapseRenotesDescription], + }, + { + id: 'igFN7RIUa', + label: i18n.ts.showNoteActionsOnlyHover, + keywords: ['hover', 'show', 'footer', 'action'], + }, + { + id: '9uxocbLO0', + label: i18n.ts.showClipButtonInNoteFooter, + keywords: ['footer', 'action', 'clip', 'show'], + }, + { + id: 'eaT1O1Fao', + label: i18n.ts.enableAdvancedMfm, + keywords: ['mfm', 'enable', 'show', 'advanced'], + }, + { + id: 'omxZk3eET', + label: i18n.ts.showReactionsCount, + keywords: ['reaction', 'count', 'show'], + }, + { + id: 'epvi2Nv2G', + label: i18n.ts.loadRawImages, + keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'quality', 'raw', 'attachment'], + }, + ], + label: i18n.ts.note, + keywords: ['note'], + }, + { + id: 'jb3HUeyrx', + children: [ + { + id: 'ykifk3NHS', + label: i18n.ts.useGroupedNotifications, + keywords: ['group'], + }, + ], + label: i18n.ts.notifications, + keywords: ['notification'], + }, + { + id: 'abEAdSpYY', + children: [ + { + id: 'lBbtAg0Hm', + label: i18n.ts.openImageInNewTab, + keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'new', 'tab'], + }, + { + id: 'E9whefUtX', + label: i18n.ts.useReactionPickerForContextMenu, + keywords: ['reaction', 'picker', 'contextmenu', 'open'], + }, + { + id: 'iQaBbJBva', + label: i18n.ts.enableInfiniteScroll, + keywords: ['load', 'auto', 'more'], + }, + { + id: 'hgEVGgJa1', + label: i18n.ts.disableStreamingTimeline, + keywords: ['disable', 'streaming', 'timeline'], + }, + { + id: 'yxehrHZ6x', + label: i18n.ts.alwaysConfirmFollow, + keywords: ['follow', 'confirm', 'always'], + }, + { + id: 'DdoFLaSG8', + label: i18n.ts.confirmWhenRevealingSensitiveMedia, + keywords: ['sensitive', 'nsfw', 'media', 'image', 'photo', 'picture', 'attachment', 'confirm'], + }, + { + id: 'uIMCIK7kG', + label: i18n.ts.confirmOnReact, + keywords: ['reaction', 'confirm'], + }, + { + id: 'zvM13vl26', + label: i18n.ts.keepCw, + keywords: ['remember', 'keep', 'note', 'cw'], + }, + { + id: 'm75VEWI3S', + label: i18n.ts.whenServerDisconnected, + keywords: ['server', 'disconnect', 'reconnect', 'reload', 'streaming'], + }, + { + id: 'bLO9vCyKW', + label: i18n.ts.numberOfPageCache, + keywords: ['cache', 'page'], + }, + { + id: 'iQ7Er89l5', + label: i18n.ts.dataSaver, + keywords: ['datasaver'], + }, + ], + label: i18n.ts.behavior, + keywords: ['behavior'], + }, + { + id: 'C2WYcVM1d', + children: [ + { + id: 'Cu7ErCM7C', + label: i18n.ts.forceShowAds, + keywords: ['ad', 'show'], + }, + { + id: 'BBxwy4F6E', + label: i18n.ts.hemisphere, + keywords: [], + }, + { + id: '9YdUwDC8d', + label: i18n.ts.additionalEmojiDictionary, + keywords: ['emoji', 'dictionary', 'additional', 'extra'], + }, + ], + label: i18n.ts.other, + keywords: [], + }, + ], + label: i18n.ts.preferences, + keywords: ['general', 'preferences'], + path: '/settings/preferences', + icon: 'ti ti-adjustments', + }, + { + id: 'mwkwtw83Y', + label: i18n.ts.plugins, + keywords: ['plugin'], + path: '/settings/plugin', + icon: 'ti ti-plug', + }, + { + id: 'F1uK9ssiY', + children: [ + { + id: 'msAcN6u3S', + label: i18n.ts.accountInfo, + keywords: ['account', 'info'], + }, + { + id: 'ts8DgdnZV', + label: i18n.ts.accountMigration, + keywords: ['account', 'move', 'migration'], + }, + { + id: '4BG7nBECm', + label: i18n.ts.closeAccount, + keywords: ['account', 'close', 'delete', i18n.ts._accountDelete.requestAccountDelete], + }, + { + id: '2qI6ruPgi', + label: i18n.ts.experimentalFeatures, + keywords: ['experimental', 'feature', 'flags'], + }, + { + id: 'cIeaax47o', + label: i18n.ts.developer, + keywords: ['developer', 'mode', 'debug'], + }, + ], + label: i18n.ts.other, + keywords: ['other'], + path: '/settings/other', + icon: 'ti ti-dots', + }, + { + id: '3icEvyv2D', + children: [ + { + id: 'Tyt3gZTy', + children: [ + { + id: '9b7ZURyAt', + label: i18n.ts.showMutedWord, + keywords: ['show'], + }, + ], + label: i18n.ts.wordMute, + keywords: ['note', 'word', 'soft', 'mute', 'hide'], + }, + { + id: 'kdMk41II0', + label: i18n.ts.hardWordMute, + keywords: ['note', 'word', 'hard', 'mute', 'hide'], + }, + { + id: 'mjORQamAK', + label: i18n.ts.instanceMute, + keywords: ['note', 'server', 'instance', 'host', 'federation', 'mute', 'hide'], + }, + { + id: '1ZT7S9FZd', + label: `${i18n.ts.mutedUsers} (${ i18n.ts.renote })`, + keywords: ['renote', 'mute', 'hide', 'user'], + }, + { + id: 'ANrPit3kQ', + label: i18n.ts.mutedUsers, + keywords: ['note', 'mute', 'hide', 'user'], + }, + { + id: 'bPAE4lfno', + label: i18n.ts.blockedUsers, + keywords: ['block', 'user'], + }, + ], + label: i18n.ts.muteAndBlock, + keywords: ['mute', 'block'], + path: '/settings/mute-block', + icon: 'ti ti-ban', + }, + { + id: 'qE2vLlMkF', + children: [ + { + id: 'hPPEzjvZC', + label: i18n.ts._exportOrImport.allNotes, + keywords: ['notes'], + }, + { + id: 'AFaeHsCUB', + label: i18n.ts._exportOrImport.favoritedNotes, + keywords: ['favorite', 'notes'], + }, + { + id: 'xyCPmQiRo', + label: i18n.ts._exportOrImport.clips, + keywords: ['clip', 'notes'], + }, + { + id: 'Ch7hWAGUy', + label: i18n.ts._exportOrImport.followingList, + keywords: ['following', 'users'], + }, + { + id: 'AwPgFboEx', + label: i18n.ts._exportOrImport.userLists, + keywords: ['user', 'lists'], + }, + { + id: 'nporiHshC', + label: i18n.ts._exportOrImport.muteList, + keywords: ['mute', 'users'], + }, + { + id: 'BsCzR7vNw', + label: i18n.ts._exportOrImport.blockingList, + keywords: ['block', 'users'], + }, + { + id: 'dvf4IgYrQ', + label: i18n.ts.antennas, + keywords: ['antennas'], + }, + ], + label: i18n.ts.importAndExport, + keywords: ['import', 'export', 'data'], + path: '/settings/import-export', + icon: 'ti ti-package', + }, + { + id: '3Tcxw4Fwl', + children: [ + { + id: 'iIai9O65I', + label: i18n.ts.emailAddress, + keywords: ['email', 'address'], + }, + { + id: 'i6cC6oi0m', + label: i18n.ts.receiveAnnouncementFromInstance, + keywords: ['announcement', 'email'], + }, + { + id: 'C1YTinP11', + label: i18n.ts.emailNotification, + keywords: ['notification', 'email'], + }, + ], + label: i18n.ts.email, + keywords: ['email'], + path: '/settings/email', + icon: 'ti ti-mail', + }, + { + id: 'tnYoppRiv', + children: [ + { + id: 'ncIq6TAR2', + label: i18n.ts.usageAmount, + keywords: ['capacity', 'usage'], + }, + { + id: '2c4CQSvSr', + label: i18n.ts.statistics, + keywords: ['statistics', 'usage'], + }, + { + id: 'pepHELHMt', + label: i18n.ts.uploadFolder, + keywords: ['default', 'upload', 'folder'], + }, + { + id: 'xqOWrABxV', + label: i18n.ts.keepOriginalUploading, + keywords: ['keep', 'original', 'raw', 'upload', i18n.ts.keepOriginalUploadingDescription], + }, + { + id: 'D8HUTGWE1', + label: i18n.ts.keepOriginalFilename, + keywords: ['keep', 'original', 'filename', i18n.ts.keepOriginalFilenameDescription], + }, + { + id: '6xAvsWSZi', + label: i18n.ts.alwaysMarkSensitive, + keywords: ['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file'], + }, + { + id: 'csNNPF1KX', + label: i18n.ts.enableAutoSensitive, + keywords: ['auto', 'nsfw', 'sensitive', 'media', 'file', i18n.ts.enableAutoSensitiveDescription], + }, + ], + label: i18n.ts.drive, + keywords: ['drive'], + path: '/settings/drive', + icon: 'ti ti-cloud', + }, + { + id: 'gtaOSdIJB', + label: i18n.ts.avatarDecorations, + keywords: ['avatar', 'icon', 'decoration'], + path: '/settings/avatar-decoration', + icon: 'ti ti-sparkles', + }, + { + id: 'AqPvMgn3A', + children: [ + { + id: 'j5gTtuMWP', + label: i18n.ts.useBlurEffect, + keywords: ['blur'], + }, + { + id: 'C05WQNSIJ', + label: i18n.ts.useBlurEffectForModal, + keywords: ['blur', 'modal'], + }, + { + id: 'snVKNr7Bw', + label: i18n.ts.highlightSensitiveMedia, + keywords: ['highlight', 'sensitive', 'nsfw', 'image', 'photo', 'picture', 'media', 'thumbnail'], + }, + { + id: 'DsS2CwjYE', + label: i18n.ts.squareAvatars, + keywords: ['avatar', 'icon', 'square'], + }, + { + id: 'xCcTDl651', + label: i18n.ts.showAvatarDecorations, + keywords: ['avatar', 'icon', 'decoration', 'show'], + }, + { + id: '3dHw723VD', + label: i18n.ts.showGapBetweenNotesInTimeline, + keywords: ['note', 'timeline', 'gap'], + }, + { + id: 'AWi72xbrl', + label: i18n.ts.seasonalScreenEffect, + keywords: ['effect', 'show'], + }, + { + id: 'Ces8FsJws', + label: i18n.ts.menuStyle, + keywords: ['menu', 'style', 'popup', 'drawer'], + }, + { + id: 'wDr9xSXCv', + label: i18n.ts.emojiStyle, + keywords: ['emoji', 'style', 'native', 'system', 'fluent', 'twemoji'], + }, + { + id: 'vFB0pLzck', + label: i18n.ts.fontSize, + keywords: ['font', 'size'], + }, + { + id: '23BhvYXPC', + label: i18n.ts.useSystemFont, + keywords: ['font', 'system', 'native'], + }, + { + id: 'EeNLndAOa', + children: [ + { + id: 'rAAPoaodS', + label: i18n.ts.reactionsDisplaySize, + keywords: ['reaction', 'size', 'scale', 'display'], + }, + { + id: 'qTLAvNWsc', + label: i18n.ts.limitWidthOfReaction, + keywords: ['reaction', 'size', 'scale', 'display', 'width', 'limit'], + }, + { + id: '2lWgzAm13', + label: i18n.ts.mediaListWithOneImageAppearance, + keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'list', 'size', 'height'], + }, + { + id: 'EU7HbxOR5', + label: i18n.ts.instanceTicker, + keywords: ['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation'], + }, + { + id: 'AEtM0FAp1', + label: i18n.ts.displayOfSensitiveMedia, + keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility'], + }, + ], + label: i18n.ts.displayOfNote, + keywords: ['note', 'display'], + }, + { + id: 'A1FMC2Zon', + children: [ + { + id: 'CB37G5ZDo', + label: i18n.ts.position, + keywords: ['position'], + }, + { + id: 'gGS2i19hS', + label: i18n.ts.stackAxis, + keywords: ['stack', 'axis', 'direction'], + }, + ], + label: i18n.ts.notificationDisplay, + keywords: ['notification', 'display'], + }, + ], + label: i18n.ts.appearance, + keywords: ['appearance'], + path: '/settings/appearance', + icon: 'ti ti-device-desktop', + }, + { + id: 'f08Mi1Uwn', + children: [ + { + id: '7ov7ceoij', + label: i18n.ts.reduceUiAnimation, + keywords: ['animation', 'motion', 'reduce'], + }, + { + id: 'cXr3tFdpa', + label: i18n.ts.disableShowingAnimatedImages, + keywords: ['disable', 'animation', 'image', 'photo', 'picture', 'media', 'thumbnail', 'gif'], + }, + { + id: 'Ok1UBwtP', + label: i18n.ts.enableAnimatedMfm, + keywords: ['mfm', 'enable', 'show', 'animated'], + }, + { + id: 'yPEpJigqY', + label: i18n.ts.enableHorizontalSwipe, + keywords: ['swipe', 'horizontal', 'tab'], + }, + { + id: 'h7iZtdTU3', + label: i18n.ts.keepScreenOn, + keywords: ['keep', 'screen', 'display', 'on'], + }, + { + id: 'gP1BY3PDy', + label: i18n.ts.useNativeUIForVideoAudioPlayer, + keywords: ['native', 'system', 'video', 'audio', 'player', 'media'], + }, + { + id: 'jnMK3M6rs', + label: i18n.ts._contextMenu.title, + keywords: ['contextmenu', 'system', 'native'], + }, + ], + label: i18n.ts.accessibility, + keywords: ['accessibility'], + path: '/settings/accessibility', + icon: 'ti ti-accessible', + }, +] as const; + +export type SearchIndex = typeof searchIndexes; diff --git a/packages/frontend/src/utility/cache.ts b/packages/frontend/src/utility/cache.ts new file mode 100644 index 0000000000..0fbdf34d5d --- /dev/null +++ b/packages/frontend/src/utility/cache.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ref } from 'vue'; + +export class Cache<T> { + private cachedAt: number | null = null; + public value = ref<T | undefined>(); + private lifetime: number; + private fetcher: () => Promise<T>; + + constructor(lifetime: Cache<never>['lifetime'], fetcher: () => Promise<T>) { + this.lifetime = lifetime; + this.fetcher = fetcher; + } + + public set(value: T): void { + this.cachedAt = Date.now(); + this.value.value = value; + } + + private get(): T | undefined { + if (this.cachedAt == null) return undefined; + if ((Date.now() - this.cachedAt) > this.lifetime) { + this.value.value = undefined; + this.cachedAt = null; + return undefined; + } + return this.value.value; + } + + public delete() { + this.cachedAt = null; + } + + /** + * キャッシュがあればそれを返し、無ければfetcherを呼び出して結果をキャッシュ&返します + */ + public async fetch(): Promise<T> { + const cachedValue = this.get(); + if (cachedValue !== undefined) { + // Cache HIT + return cachedValue; + } + + // Cache MISS + const value = await this.fetcher(); + this.set(value); + return value; + } +} diff --git a/packages/frontend/src/utility/chart-legend.ts b/packages/frontend/src/utility/chart-legend.ts new file mode 100644 index 0000000000..e701d18dd2 --- /dev/null +++ b/packages/frontend/src/utility/chart-legend.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Plugin } from 'chart.js'; +import MkChartLegend from '@/components/MkChartLegend.vue'; + +export const chartLegend = (legend: InstanceType<typeof MkChartLegend>) => ({ + id: 'htmlLegend', + afterUpdate(chart, args, options) { + // Reuse the built-in legendItems generator + const items = chart.options.plugins.legend.labels.generateLabels(chart); + + legend.update(chart, items); + }, +}) as Plugin; diff --git a/packages/frontend/src/utility/chart-vline.ts b/packages/frontend/src/utility/chart-vline.ts new file mode 100644 index 0000000000..465ca591c6 --- /dev/null +++ b/packages/frontend/src/utility/chart-vline.ts @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Plugin } from 'chart.js'; + +export const chartVLine = (vLineColor: string) => ({ + id: 'vLine', + beforeDraw(chart, args, options) { + if (chart.tooltip?._active?.length) { + const ctx = chart.ctx; + const xs = chart.tooltip._active.map(a => a.element.x); + const x = xs.reduce((a, b) => a + b, 0) / xs.length; + const topY = chart.scales.y.top; + const bottomY = chart.scales.y.bottom; + + ctx.save(); + ctx.beginPath(); + ctx.moveTo(x, bottomY); + ctx.lineTo(x, topY); + ctx.lineWidth = 1; + ctx.strokeStyle = vLineColor; + ctx.stroke(); + ctx.restore(); + } + }, +}) as Plugin; diff --git a/packages/frontend/src/utility/check-permissions.ts b/packages/frontend/src/utility/check-permissions.ts new file mode 100644 index 0000000000..ed86529d5b --- /dev/null +++ b/packages/frontend/src/utility/check-permissions.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { instance } from '@/instance.js'; +import { $i } from '@/account.js'; + +export const notesSearchAvailable = ( + // FIXME: instance.policies would be null in Vitest + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + ($i == null && instance.policies != null && instance.policies.canSearchNotes) || + ($i != null && $i.policies.canSearchNotes) || + false +) as boolean; + +export const canSearchNonLocalNotes = ( + instance.noteSearchableScope === 'global' +); diff --git a/packages/frontend/src/utility/check-reaction-permissions.ts b/packages/frontend/src/utility/check-reaction-permissions.ts new file mode 100644 index 0000000000..281ea2520e --- /dev/null +++ b/packages/frontend/src/utility/check-reaction-permissions.ts @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import type { UnicodeEmojiDef } from '@@/js/emojilist.js'; + +export function checkReactionPermissions(me: Misskey.entities.MeDetailed, note: Misskey.entities.Note, emoji: Misskey.entities.EmojiSimple | UnicodeEmojiDef | string): boolean { + if (typeof emoji === 'string') return true; // UnicodeEmojiDefにも無い絵文字であれば文字列で来る。Unicode絵文字であることには変わりないので常にリアクション可能とする; + if ('char' in emoji) return true; // UnicodeEmojiDefなら常にリアクション可能 + + const roleIdsThatCanBeUsedThisEmojiAsReaction = emoji.roleIdsThatCanBeUsedThisEmojiAsReaction ?? []; + return !(emoji.localOnly && note.user.host !== me.host) + && !(emoji.isSensitive && (note.reactionAcceptance === 'nonSensitiveOnly' || note.reactionAcceptance === 'nonSensitiveOnlyForLocalLikeOnlyForRemote')) + && (roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0 || me.roles.some(role => roleIdsThatCanBeUsedThisEmojiAsReaction.includes(role.id))); +} diff --git a/packages/frontend/src/utility/check-word-mute.ts b/packages/frontend/src/utility/check-word-mute.ts new file mode 100644 index 0000000000..98fea1bced --- /dev/null +++ b/packages/frontend/src/utility/check-word-mute.ts @@ -0,0 +1,43 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +import * as Misskey from 'misskey-js'; + +export function checkWordMute(note: Misskey.entities.Note, me: Misskey.entities.UserLite | null | undefined, mutedWords: Array<string | string[]>): Array<string | string[]> | false { + // 自分自身 + if (me && (note.userId === me.id)) return false; + + if (mutedWords.length > 0) { + const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim(); + + if (text === '') return false; + + const matched = mutedWords.filter(filter => { + if (Array.isArray(filter)) { + // Clean up + const filteredFilter = filter.filter(keyword => keyword !== ''); + if (filteredFilter.length === 0) return false; + + return filteredFilter.every(keyword => text.includes(keyword)); + } else { + // represents RegExp + const regexp = filter.match(/^\/(.+)\/(.*)$/); + + // This should never happen due to input sanitisation. + if (!regexp) return false; + + try { + return new RegExp(regexp[1], regexp[2]).test(text); + } catch (err) { + // This should never happen due to input sanitisation. + return false; + } + } + }); + + if (matched.length > 0) return matched; + } + + return false; +} diff --git a/packages/frontend/src/utility/clear-cache.ts b/packages/frontend/src/utility/clear-cache.ts new file mode 100644 index 0000000000..b6ae254727 --- /dev/null +++ b/packages/frontend/src/utility/clear-cache.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { unisonReload } from '@/utility/unison-reload.js'; +import * as os from '@/os.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { fetchCustomEmojis } from '@/custom-emojis.js'; +import { fetchInstance } from '@/instance.js'; + +export async function clearCache() { + os.waiting(); + miLocalStorage.removeItem('instance'); + miLocalStorage.removeItem('instanceCachedAt'); + miLocalStorage.removeItem('locale'); + miLocalStorage.removeItem('localeVersion'); + miLocalStorage.removeItem('theme'); + miLocalStorage.removeItem('emojis'); + miLocalStorage.removeItem('lastEmojisFetchedAt'); + await fetchInstance(true); + await fetchCustomEmojis(true); + unisonReload(); +} diff --git a/packages/frontend/src/utility/clicker-game.ts b/packages/frontend/src/utility/clicker-game.ts new file mode 100644 index 0000000000..0544be7757 --- /dev/null +++ b/packages/frontend/src/utility/clicker-game.ts @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ref, computed } from 'vue'; +import { misskeyApi } from '@/utility/misskey-api.js'; + +type SaveData = { + gameVersion: number; + cookies: number; + totalCookies: number; + totalHandmadeCookies: number; + clicked: number; + achievements: any[]; + facilities: any[]; +}; + +export const saveData = ref<SaveData>(); +export const ready = computed(() => saveData.value != null); + +let prev = ''; + +export async function load() { + try { + saveData.value = await misskeyApi('i/registry/get', { + scope: ['clickerGame'], + key: 'saveData', + }); + } catch (err) { + if (err.code === 'NO_SUCH_KEY') { + saveData.value = { + gameVersion: 2, + cookies: 0, + totalCookies: 0, + totalHandmadeCookies: 0, + clicked: 0, + achievements: [], + facilities: [], + }; + save(); + return; + } + throw err; + } + + // migration + if (saveData.value.gameVersion === 1) { + saveData.value = { + gameVersion: 2, + cookies: saveData.value.cookies, + totalCookies: saveData.value.cookies, + totalHandmadeCookies: saveData.value.cookies, + clicked: saveData.value.clicked, + achievements: [], + facilities: [], + }; + save(); + } +} + +export async function save() { + const current = JSON.stringify(saveData.value); + if (current === prev) return; + + await misskeyApi('i/registry/set', { + scope: ['clickerGame'], + key: 'saveData', + value: saveData.value, + }); + + prev = current; +} diff --git a/packages/frontend/src/utility/clone.ts b/packages/frontend/src/utility/clone.ts new file mode 100644 index 0000000000..ea8eea14b5 --- /dev/null +++ b/packages/frontend/src/utility/clone.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// structredCloneが遅いため +// SEE: http://var.blog.jp/archives/86038606.html +// あと、Vue RefをIndexedDBに保存しようとしてstructredCloneを使ったらエラーになった +// https://github.com/misskey-dev/misskey/pull/8098#issuecomment-1114144045 + +export type Cloneable = string | number | boolean | null | undefined | { [key: string]: Cloneable } | { [key: number]: Cloneable } | { [key: symbol]: Cloneable } | Cloneable[]; + +export function deepClone<T extends Cloneable>(x: T): T { + if (typeof x === 'object') { + if (x === null) return x; + if (Array.isArray(x)) return x.map(deepClone) as T; + const obj = {} as Record<string | number | symbol, Cloneable>; + for (const [k, v] of Object.entries(x)) { + obj[k] = v === undefined ? undefined : deepClone(v); + } + return obj as T; + } else { + return x; + } +} diff --git a/packages/frontend/src/utility/code-highlighter.ts b/packages/frontend/src/utility/code-highlighter.ts new file mode 100644 index 0000000000..4f2aff9d4c --- /dev/null +++ b/packages/frontend/src/utility/code-highlighter.ts @@ -0,0 +1,99 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { createHighlighterCore } from 'shiki/core'; +import { createOnigurumaEngine } from 'shiki/engine/oniguruma'; +import darkPlus from 'shiki/themes/dark-plus.mjs'; +import { bundledThemesInfo } from 'shiki/themes'; +import { bundledLanguagesInfo } from 'shiki/langs'; +import lightTheme from '@@/themes/_light.json5'; +import darkTheme from '@@/themes/_dark.json5'; +import defaultLightTheme from '@@/themes/l-light.json5'; +import defaultDarkTheme from '@@/themes/d-green-lime.json5'; +import { unique } from './array.js'; +import { deepClone } from './clone.js'; +import { deepMerge } from './merge.js'; +import type { HighlighterCore, LanguageRegistration, ThemeRegistration, ThemeRegistrationRaw } from 'shiki/core'; +import { prefer } from '@/preferences.js'; + +let _highlighter: HighlighterCore | null = null; + +export async function getTheme(mode: 'light' | 'dark', getName: true): Promise<string>; +export async function getTheme(mode: 'light' | 'dark', getName?: false): Promise<ThemeRegistration | ThemeRegistrationRaw>; +export async function getTheme(mode: 'light' | 'dark', getName = false): Promise<ThemeRegistration | ThemeRegistrationRaw | string | null> { + const theme = deepClone(mode === 'light' ? prefer.s.lightTheme ?? defaultLightTheme : prefer.s.darkTheme ?? defaultDarkTheme); + + if (theme.base) { + const base = [lightTheme, darkTheme].find(x => x.id === theme.base); + if (base && base.codeHighlighter) theme.codeHighlighter = Object.assign({}, base.codeHighlighter, theme.codeHighlighter); + } + + if (theme.codeHighlighter) { + let _res: ThemeRegistration = {}; + if (theme.codeHighlighter.base === '_none_') { + _res = deepClone(theme.codeHighlighter.overrides); + } else { + const base = await bundledThemesInfo.find(t => t.id === theme.codeHighlighter!.base)?.import() ?? darkPlus; + _res = deepMerge(theme.codeHighlighter.overrides ?? {}, 'default' in base ? base.default : base); + } + if (_res.name == null) { + _res.name = theme.id; + } + _res.type = mode; + + if (getName) { + return _res.name; + } + return _res; + } + + if (getName) { + return 'dark-plus'; + } + return darkPlus; +} + +export async function getHighlighter(): Promise<HighlighterCore> { + if (!_highlighter) { + return await initHighlighter(); + } + return _highlighter; +} + +async function initHighlighter() { + // テーマの重複を消す + const themes = unique([ + darkPlus, + ...(await Promise.all([getTheme('light'), getTheme('dark')])), + ]); + + const jsLangInfo = bundledLanguagesInfo.find(t => t.id === 'javascript'); + const highlighter = await createHighlighterCore({ + engine: createOnigurumaEngine(() => import('shiki/onig.wasm?init')), + themes, + langs: [ + ...(jsLangInfo ? [async () => await jsLangInfo.import()] : []), + async () => (await import('aiscript-vscode/aiscript/syntaxes/aiscript.tmLanguage.json')).default as unknown as LanguageRegistration, + ], + }); + + // TODO + //watch('lightTheme', async () => { + // const newTheme = await getTheme('light'); + // if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) { + // highlighter.loadTheme(newTheme); + // } + //}); + //watch('darkTheme', async () => { + // const newTheme = await getTheme('dark'); + // if (newTheme.name && !highlighter.getLoadedThemes().includes(newTheme.name)) { + // highlighter.loadTheme(newTheme); + // } + //}); + + _highlighter = highlighter; + + return highlighter; +} diff --git a/packages/frontend/src/utility/collect-page-vars.ts b/packages/frontend/src/utility/collect-page-vars.ts new file mode 100644 index 0000000000..5096c0669e --- /dev/null +++ b/packages/frontend/src/utility/collect-page-vars.ts @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +interface StringPageVar { + name: string, + type: 'string', + value: string +} + +interface NumberPageVar { + name: string, + type: 'number', + value: number +} + +interface BooleanPageVar { + name: string, + type: 'boolean', + value: boolean +} + +type PageVar = StringPageVar | NumberPageVar | BooleanPageVar; + +export function collectPageVars(content): PageVar[] { + const pageVars: PageVar[] = []; + const collect = (xs: any[]): void => { + for (const x of xs) { + if (x.type === 'textInput') { + pageVars.push({ + name: x.name, + type: 'string', + value: x.default || '', + }); + } else if (x.type === 'textareaInput') { + pageVars.push({ + name: x.name, + type: 'string', + value: x.default || '', + }); + } else if (x.type === 'numberInput') { + pageVars.push({ + name: x.name, + type: 'number', + value: x.default || 0, + }); + } else if (x.type === 'switch') { + pageVars.push({ + name: x.name, + type: 'boolean', + value: x.default || false, + }); + } else if (x.type === 'counter') { + pageVars.push({ + name: x.name, + type: 'number', + value: 0, + }); + } else if (x.type === 'radioButton') { + pageVars.push({ + name: x.name, + type: 'string', + value: x.default || '', + }); + } else if (x.children) { + collect(x.children); + } + } + }; + collect(content); + return pageVars; +} diff --git a/packages/frontend/src/utility/color.ts b/packages/frontend/src/utility/color.ts new file mode 100644 index 0000000000..a11255ffd1 --- /dev/null +++ b/packages/frontend/src/utility/color.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const alpha = (hex: string, a: number): string => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; diff --git a/packages/frontend/src/utility/confetti.ts b/packages/frontend/src/utility/confetti.ts new file mode 100644 index 0000000000..8e53a6ceeb --- /dev/null +++ b/packages/frontend/src/utility/confetti.ts @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import _confetti from 'canvas-confetti'; +import * as os from '@/os.js'; + +export function confetti(options: { duration?: number; } = {}) { + const duration = options.duration ?? 1000 * 4; + const animationEnd = Date.now() + duration; + const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: os.claimZIndex('high') }; + + function randomInRange(min, max) { + return Math.random() * (max - min) + min; + } + + const interval = setInterval(() => { + const timeLeft = animationEnd - Date.now(); + + if (timeLeft <= 0) { + return clearInterval(interval); + } + + const particleCount = 50 * (timeLeft / duration); + // since particles fall down, start a bit higher than random + _confetti(Object.assign({}, defaults, { particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } })); + _confetti(Object.assign({}, defaults, { particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } })); + }, 250); +} diff --git a/packages/frontend/src/utility/contains.ts b/packages/frontend/src/utility/contains.ts new file mode 100644 index 0000000000..6137c06e85 --- /dev/null +++ b/packages/frontend/src/utility/contains.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export default (parent, child, checkSame = true) => { + if (checkSame && parent === child) return true; + let node = child.parentNode; + while (node) { + if (node === parent) return true; + node = node.parentNode; + } + return false; +}; diff --git a/packages/frontend/src/utility/copy-to-clipboard.ts b/packages/frontend/src/utility/copy-to-clipboard.ts new file mode 100644 index 0000000000..7e0bb25606 --- /dev/null +++ b/packages/frontend/src/utility/copy-to-clipboard.ts @@ -0,0 +1,11 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * Clipboardに値をコピー(TODO: 文字列以外も対応) + */ +export function copyToClipboard(input: string | null) { + if (input) navigator.clipboard.writeText(input); +}; diff --git a/packages/frontend/src/utility/device-kind.ts b/packages/frontend/src/utility/device-kind.ts new file mode 100644 index 0000000000..7aadb617ca --- /dev/null +++ b/packages/frontend/src/utility/device-kind.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type DeviceKind = 'smartphone' | 'tablet' | 'desktop'; + +const ua = navigator.userAgent.toLowerCase(); +const isTablet = /ipad/.test(ua) || (/mobile|iphone|android/.test(ua) && window.innerWidth > 700); +const isSmartphone = !isTablet && /mobile|iphone|android/.test(ua); + +export const DEFAULT_DEVICE_KIND: DeviceKind = ( + isSmartphone + ? 'smartphone' + : isTablet + ? 'tablet' + : 'desktop' +); + +export let deviceKind: DeviceKind = DEFAULT_DEVICE_KIND; + +export function updateDeviceKind(kind: DeviceKind | null) { + deviceKind = kind ?? DEFAULT_DEVICE_KIND; +} diff --git a/packages/frontend/src/utility/emoji-picker.ts b/packages/frontend/src/utility/emoji-picker.ts new file mode 100644 index 0000000000..7ff5863243 --- /dev/null +++ b/packages/frontend/src/utility/emoji-picker.ts @@ -0,0 +1,61 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineAsyncComponent, ref } from 'vue'; +import type { Ref } from 'vue'; +import { popup } from '@/os.js'; +import { store } from '@/store.js'; + +/** + * 絵文字ピッカーを表示する。 + * 類似の機能として{@link ReactionPicker}が存在しているが、この機能とは動きが異なる。 + * 投稿フォームなどで絵文字を選択する時など、絵文字ピックアップ後でもダイアログが消えずに残り、 + * 一度表示したダイアログを連続で使用できることが望ましいシーンでの利用が想定される。 + */ +class EmojiPicker { + private src: Ref<HTMLElement | null> = ref(null); + private manualShowing = ref(false); + private onChosen?: (emoji: string) => void; + private onClosed?: () => void; + + constructor() { + // nop + } + + public async init() { + const emojisRef = store.reactiveState.pinnedEmojis; + await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { + src: this.src, + pinnedEmojis: emojisRef, + asReactionPicker: false, + manualShowing: this.manualShowing, + choseAndClose: false, + }, { + done: emoji => { + if (this.onChosen) this.onChosen(emoji); + }, + close: () => { + this.manualShowing.value = false; + }, + closed: () => { + this.src.value = null; + if (this.onClosed) this.onClosed(); + }, + }); + } + + public show( + src: HTMLElement, + onChosen?: EmojiPicker['onChosen'], + onClosed?: EmojiPicker['onClosed'], + ) { + this.src.value = src; + this.manualShowing.value = true; + this.onChosen = onChosen; + this.onClosed = onClosed; + } +} + +export const emojiPicker = new EmojiPicker(); diff --git a/packages/frontend/src/utility/extract-mentions.ts b/packages/frontend/src/utility/extract-mentions.ts new file mode 100644 index 0000000000..d518562053 --- /dev/null +++ b/packages/frontend/src/utility/extract-mentions.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// test is located in test/extract-mentions + +import * as mfm from 'mfm-js'; + +export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] { + // TODO: 重複を削除 + const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention'); + const mentions = mentionNodes.map(x => x.props); + + return mentions; +} diff --git a/packages/frontend/src/utility/extract-url-from-mfm.ts b/packages/frontend/src/utility/extract-url-from-mfm.ts new file mode 100644 index 0000000000..570823d5b5 --- /dev/null +++ b/packages/frontend/src/utility/extract-url-from-mfm.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as mfm from 'mfm-js'; +import { unique } from '@/utility/array.js'; + +// unique without hash +// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ] +const removeHash = (x: string) => x.replace(/#[^#]*$/, ''); + +export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] { + const urlNodes = mfm.extract(nodes, (node) => { + return (node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent)); + }); + const urls: string[] = unique(urlNodes.map(x => x.props.url)); + + return urls.reduce((array, url) => { + const urlWithoutHash = removeHash(url); + if (!array.map(x => removeHash(x)).includes(urlWithoutHash)) array.push(url); + return array; + }, [] as string[]); +} diff --git a/packages/frontend/src/utility/file-drop.ts b/packages/frontend/src/utility/file-drop.ts new file mode 100644 index 0000000000..4259fe25e9 --- /dev/null +++ b/packages/frontend/src/utility/file-drop.ts @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type DroppedItem = DroppedFile | DroppedDirectory; + +export type DroppedFile = { + isFile: true; + path: string; + file: File; +}; + +export type DroppedDirectory = { + isFile: false; + path: string; + children: DroppedItem[]; +}; + +export async function extractDroppedItems(ev: DragEvent): Promise<DroppedItem[]> { + const dropItems = ev.dataTransfer?.items; + if (!dropItems || dropItems.length === 0) { + return []; + } + + const apiTestItem = dropItems[0]; + if ('webkitGetAsEntry' in apiTestItem) { + return readDataTransferItems(dropItems); + } else { + // webkitGetAsEntryに対応していない場合はfilesから取得する(ディレクトリのサポートは出来ない) + const dropFiles = ev.dataTransfer.files; + if (dropFiles.length === 0) { + return []; + } + + const droppedFiles = Array.of<DroppedFile>(); + for (let i = 0; i < dropFiles.length; i++) { + const file = dropFiles.item(i); + if (file) { + droppedFiles.push({ + isFile: true, + path: file.name, + file, + }); + } + } + + return droppedFiles; + } +} + +/** + * ドラッグ&ドロップされたファイルのリストからディレクトリ構造とファイルへの参照({@link File})を取得する。 + */ +export async function readDataTransferItems(itemList: DataTransferItemList): Promise<DroppedItem[]> { + async function readEntry(entry: FileSystemEntry): Promise<DroppedItem> { + if (entry.isFile) { + return { + isFile: true, + path: entry.fullPath, + file: await readFile(entry as FileSystemFileEntry), + }; + } else { + return { + isFile: false, + path: entry.fullPath, + children: await readDirectory(entry as FileSystemDirectoryEntry), + }; + } + } + + function readFile(fileSystemFileEntry: FileSystemFileEntry): Promise<File> { + return new Promise((resolve, reject) => { + fileSystemFileEntry.file(resolve, reject); + }); + } + + function readDirectory(fileSystemDirectoryEntry: FileSystemDirectoryEntry): Promise<DroppedItem[]> { + return new Promise(async (resolve) => { + const allEntries = Array.of<FileSystemEntry>(); + const reader = fileSystemDirectoryEntry.createReader(); + while (true) { + const entries = await new Promise<FileSystemEntry[]>((res, rej) => reader.readEntries(res, rej)); + if (entries.length === 0) { + break; + } + allEntries.push(...entries); + } + + resolve(await Promise.all(allEntries.map(readEntry))); + }); + } + + // 扱いにくいので配列に変換 + const items = Array.of<DataTransferItem>(); + for (let i = 0; i < itemList.length; i++) { + items.push(itemList[i]); + } + + return Promise.all( + items + .map(it => it.webkitGetAsEntry()) + .filter(it => it) + .map(it => readEntry(it!)), + ); +} + +/** + * {@link DroppedItem}のリストからディレクトリを再帰的に検索し、ファイルのリストを取得する。 + */ +export function flattenDroppedFiles(items: DroppedItem[]): DroppedFile[] { + const result = Array.of<DroppedFile>(); + for (const item of items) { + if (item.isFile) { + result.push(item); + } else { + result.push(...flattenDroppedFiles(item.children)); + } + } + return result; +} diff --git a/packages/frontend/src/utility/focus-trap.ts b/packages/frontend/src/utility/focus-trap.ts new file mode 100644 index 0000000000..fd17fa38a0 --- /dev/null +++ b/packages/frontend/src/utility/focus-trap.ts @@ -0,0 +1,134 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { getHTMLElementOrNull } from '@/utility/get-dom-node-or-null.js'; + +const focusTrapElements = new Set<HTMLElement>(); +const ignoreElements = [ + 'script', + 'style', +]; + +function containsFocusTrappedElements(el: HTMLElement): boolean { + return Array.from(focusTrapElements).some((focusTrapElement) => { + return el.contains(focusTrapElement); + }); +} + +function getZIndex(el: HTMLElement): number { + const zIndex = parseInt(window.getComputedStyle(el).zIndex || '0', 10); + if (isNaN(zIndex)) { + return 0; + } + return zIndex; +} + +function getHighestZIndexElement(): { el: HTMLElement; zIndex: number; } | null { + let highestZIndexElement: HTMLElement | null = null; + let highestZIndex = -Infinity; + + focusTrapElements.forEach((el) => { + const zIndex = getZIndex(el); + if (zIndex > highestZIndex) { + highestZIndex = zIndex; + highestZIndexElement = el; + } + }); + + return highestZIndexElement == null ? null : { + el: highestZIndexElement, + zIndex: highestZIndex, + }; +} + +function releaseFocusTrap(el: HTMLElement): void { + focusTrapElements.delete(el); + if (el.inert === true) { + el.inert = false; + } + + const highestZIndexElement = getHighestZIndexElement(); + + if (el.parentElement != null && el !== document.body) { + el.parentElement.childNodes.forEach((siblingNode) => { + const siblingEl = getHTMLElementOrNull(siblingNode); + if (!siblingEl) return; + if ( + siblingEl !== el && + ( + highestZIndexElement == null || + siblingEl === highestZIndexElement.el || + siblingEl.contains(highestZIndexElement.el) + ) + ) { + siblingEl.inert = false; + } else if ( + highestZIndexElement != null && + siblingEl !== highestZIndexElement.el && + !siblingEl.contains(highestZIndexElement.el) && + !ignoreElements.includes(siblingEl.tagName.toLowerCase()) + ) { + siblingEl.inert = true; + } else { + siblingEl.inert = false; + } + }); + releaseFocusTrap(el.parentElement); + } +} + +export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls: boolean, parent: true): void; +export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls?: boolean, parent?: false): { release: () => void; }; +export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls = false, parent = false): { release: () => void; } | void { + const highestZIndexElement = getHighestZIndexElement(); + + const highestZIndex = highestZIndexElement == null ? -Infinity : highestZIndexElement.zIndex; + const zIndex = getZIndex(el); + + // If the element has a lower z-index than the highest z-index element, focus trap the highest z-index element instead + // Focus trapping for this element will be done in the release function + if (!parent && zIndex < highestZIndex) { + focusTrapElements.add(el); + if (highestZIndexElement) { + focusTrap(highestZIndexElement.el, hasInteractionWithOtherFocusTrappedEls); + } + return { + release: () => { + releaseFocusTrap(el); + }, + }; + } + + if (el.inert === true) { + el.inert = false; + } + + if (el.parentElement != null && el !== document.body) { + el.parentElement.childNodes.forEach((siblingNode) => { + const siblingEl = getHTMLElementOrNull(siblingNode); + if (!siblingEl) return; + if ( + siblingEl !== el && + ( + hasInteractionWithOtherFocusTrappedEls === false || + (!focusTrapElements.has(siblingEl) && !containsFocusTrappedElements(siblingEl)) + ) && + !ignoreElements.includes(siblingEl.tagName.toLowerCase()) + ) { + siblingEl.inert = true; + } + }); + focusTrap(el.parentElement, hasInteractionWithOtherFocusTrappedEls, true); + } + + if (!parent) { + focusTrapElements.add(el); + + return { + release: () => { + releaseFocusTrap(el); + }, + }; + } +} diff --git a/packages/frontend/src/utility/focus.ts b/packages/frontend/src/utility/focus.ts new file mode 100644 index 0000000000..e3fd928d1d --- /dev/null +++ b/packages/frontend/src/utility/focus.ts @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { getScrollPosition, getScrollContainer, getStickyBottom, getStickyTop } from '@@/js/scroll.js'; +import { getElementOrNull, getNodeOrNull } from '@/utility/get-dom-node-or-null.js'; + +type MaybeHTMLElement = EventTarget | Node | Element | HTMLElement; + +export const isFocusable = (input: MaybeHTMLElement | null | undefined): input is HTMLElement => { + if (input == null || !(input instanceof HTMLElement)) return false; + + if (input.tabIndex < 0) return false; + if ('disabled' in input && input.disabled === true) return false; + if ('readonly' in input && input.readonly === true) return false; + + if (!input.ownerDocument.contains(input)) return false; + + const style = window.getComputedStyle(input); + if (style.display === 'none') return false; + if (style.visibility === 'hidden') return false; + if (style.opacity === '0') return false; + if (style.pointerEvents === 'none') return false; + + return true; +}; + +export const focusPrev = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => { + const element = self ? input : getElementOrNull(input)?.previousElementSibling; + if (element == null) return; + if (isFocusable(element)) { + focusOrScroll(element, scroll); + } else { + focusPrev(element, false, scroll); + } +}; + +export const focusNext = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => { + const element = self ? input : getElementOrNull(input)?.nextElementSibling; + if (element == null) return; + if (isFocusable(element)) { + focusOrScroll(element, scroll); + } else { + focusNext(element, false, scroll); + } +}; + +export const focusParent = (input: MaybeHTMLElement | null | undefined, self = false, scroll = true) => { + const element = self ? input : getNodeOrNull(input)?.parentElement; + if (element == null) return; + if (isFocusable(element)) { + focusOrScroll(element, scroll); + } else { + focusParent(element, false, scroll); + } +}; + +const focusOrScroll = (element: HTMLElement, scroll: boolean) => { + if (scroll) { + const scrollContainer = getScrollContainer(element) ?? document.documentElement; + const scrollContainerTop = getScrollPosition(scrollContainer); + const stickyTop = getStickyTop(element, scrollContainer); + const stickyBottom = getStickyBottom(element, scrollContainer); + const top = element.getBoundingClientRect().top; + const bottom = element.getBoundingClientRect().bottom; + + let scrollTo = scrollContainerTop; + if (top < stickyTop) { + scrollTo += top - stickyTop; + } else if (bottom > window.innerHeight - stickyBottom) { + scrollTo += bottom - window.innerHeight + stickyBottom; + } + scrollContainer.scrollTo({ top: scrollTo, behavior: 'instant' }); + } + + if (document.activeElement !== element) { + element.focus({ preventScroll: true }); + } +}; diff --git a/packages/frontend/src/utility/form.ts b/packages/frontend/src/utility/form.ts new file mode 100644 index 0000000000..1032e97ac9 --- /dev/null +++ b/packages/frontend/src/utility/form.ts @@ -0,0 +1,104 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; + +type EnumItem = string | { + label: string; + value: string; +}; + +type Hidden = boolean | ((v: any) => boolean); + +export type FormItem = { + label?: string; + type: 'string'; + default?: string | null; + description?: string; + required?: boolean; + hidden?: Hidden; + multiline?: boolean; + treatAsMfm?: boolean; +} | { + label?: string; + type: 'number'; + default?: number | null; + description?: string; + required?: boolean; + hidden?: Hidden; + step?: number; +} | { + label?: string; + type: 'boolean'; + default?: boolean | null; + description?: string; + hidden?: Hidden; +} | { + label?: string; + type: 'enum'; + default?: string | null; + required?: boolean; + hidden?: Hidden; + enum: EnumItem[]; +} | { + label?: string; + type: 'radio'; + default?: unknown | null; + required?: boolean; + hidden?: Hidden; + options: { + label: string; + value: unknown; + }[]; +} | { + label?: string; + type: 'range'; + default?: number | null; + description?: string; + required?: boolean; + step?: number; + min: number; + max: number; + textConverter?: (value: number) => string; + hidden?: Hidden; +} | { + label?: string; + type: 'object'; + default?: Record<string, unknown> | null; + hidden: Hidden; +} | { + label?: string; + type: 'array'; + default?: unknown[] | null; + hidden: Hidden; +} | { + type: 'button'; + content?: string; + hidden?: Hidden; + action: (ev: MouseEvent, v: any) => void; +} | { + type: 'drive-file'; + defaultFileId?: string | null; + hidden?: Hidden; + validate?: (v: Misskey.entities.DriveFile) => Promise<boolean>; +}; + +export type Form = Record<string, FormItem>; + +type GetItemType<Item extends FormItem> = + Item['type'] extends 'string' ? string : + Item['type'] extends 'number' ? number : + Item['type'] extends 'boolean' ? boolean : + Item['type'] extends 'radio' ? unknown : + Item['type'] extends 'range' ? number : + Item['type'] extends 'enum' ? string : + Item['type'] extends 'array' ? unknown[] : + Item['type'] extends 'object' ? Record<string, unknown> : + Item['type'] extends 'drive-file' ? Misskey.entities.DriveFile | undefined : + never; + +export type GetFormResultType<F extends Form> = { + [P in keyof F]: GetItemType<F[P]>; +}; diff --git a/packages/frontend/src/utility/format-time-string.ts b/packages/frontend/src/utility/format-time-string.ts new file mode 100644 index 0000000000..d383f143e1 --- /dev/null +++ b/packages/frontend/src/utility/format-time-string.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const defaultLocaleStringFormats: { [index: string]: string } = { + 'weekday': 'narrow', + 'era': 'narrow', + 'year': 'numeric', + 'month': 'numeric', + 'day': 'numeric', + 'hour': 'numeric', + 'minute': 'numeric', + 'second': 'numeric', + 'timeZoneName': 'short', +}; + +function formatLocaleString(date: Date, format: string): string { + return format.replace(/\{\{(\w+)(:(\w+))?\}\}/g, (match: string, kind: string, unused?, option?: string) => { + if (['weekday', 'era', 'year', 'month', 'day', 'hour', 'minute', 'second', 'timeZoneName'].includes(kind)) { + return date.toLocaleString(window.navigator.language, { [kind]: option ? option : defaultLocaleStringFormats[kind] }); + } else { + return match; + } + }); +} + +export function formatDateTimeString(date: Date, format: string): string { + return format + .replace(/yyyy/g, date.getFullYear().toString()) + .replace(/yy/g, date.getFullYear().toString().slice(-2)) + .replace(/MMMM/g, date.toLocaleString(window.navigator.language, { month: 'long' })) + .replace(/MMM/g, date.toLocaleString(window.navigator.language, { month: 'short' })) + .replace(/MM/g, (`0${date.getMonth() + 1}`).slice(-2)) + .replace(/M/g, (date.getMonth() + 1).toString()) + .replace(/dd/g, (`0${date.getDate()}`).slice(-2)) + .replace(/d/g, date.getDate().toString()) + .replace(/HH/g, (`0${date.getHours()}`).slice(-2)) + .replace(/H/g, date.getHours().toString()) + .replace(/hh/g, (`0${(date.getHours() % 12) || 12}`).slice(-2)) + .replace(/h/g, ((date.getHours() % 12) || 12).toString()) + .replace(/mm/g, (`0${date.getMinutes()}`).slice(-2)) + .replace(/m/g, date.getMinutes().toString()) + .replace(/ss/g, (`0${date.getSeconds()}`).slice(-2)) + .replace(/s/g, date.getSeconds().toString()) + .replace(/tt/g, date.getHours() >= 12 ? 'PM' : 'AM'); +} + +export function formatTimeString(date: Date, format: string): string { + return format.replace(/\[(([^\[]|\[\])*)\]|(([yMdHhmst])\4{0,3})/g, (match: string, localeformat?: string, unused?, datetimeformat?: string) => { + if (localeformat) return formatLocaleString(date, localeformat); + if (datetimeformat) return formatDateTimeString(date, datetimeformat); + return match; + }); +} diff --git a/packages/frontend/src/utility/fullscreen.ts b/packages/frontend/src/utility/fullscreen.ts new file mode 100644 index 0000000000..7a0a018ef3 --- /dev/null +++ b/packages/frontend/src/utility/fullscreen.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +type PartiallyPartial<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>; + +type VideoEl = PartiallyPartial<HTMLVideoElement, 'requestFullscreen'> & { + webkitEnterFullscreen?(): void; + webkitExitFullscreen?(): void; +}; + +type PlayerEl = PartiallyPartial<HTMLElement, 'requestFullscreen'>; + +type RequestFullscreenProps = { + readonly videoEl: VideoEl; + readonly playerEl: PlayerEl; + readonly options?: FullscreenOptions | null; +}; + +type ExitFullscreenProps = { + readonly videoEl: VideoEl; +}; + +export const requestFullscreen = ({ videoEl, playerEl, options }: RequestFullscreenProps) => { + if (playerEl.requestFullscreen != null) { + playerEl.requestFullscreen(options ?? undefined); + return; + } + if (videoEl.webkitEnterFullscreen != null) { + videoEl.webkitEnterFullscreen(); + return; + } +}; + +export const exitFullscreen = ({ videoEl }: ExitFullscreenProps) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (document.exitFullscreen != null) { + document.exitFullscreen(); + return; + } + if (videoEl.webkitExitFullscreen != null) { + videoEl.webkitExitFullscreen(); + return; + } +}; diff --git a/packages/frontend/src/utility/get-account-from-id.ts b/packages/frontend/src/utility/get-account-from-id.ts new file mode 100644 index 0000000000..5d9662a747 --- /dev/null +++ b/packages/frontend/src/utility/get-account-from-id.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { get } from '@/utility/idb-proxy.js'; + +export async function getAccountFromId(id: string) { + const accounts = await get('accounts') as { token: string; id: string; }[]; + if (!accounts) console.log('Accounts are not recorded'); + return accounts.find(account => account.id === id); +} diff --git a/packages/frontend/src/utility/get-appear-note.ts b/packages/frontend/src/utility/get-appear-note.ts new file mode 100644 index 0000000000..40ce80eac9 --- /dev/null +++ b/packages/frontend/src/utility/get-appear-note.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; + +export function getAppearNote(note: Misskey.entities.Note) { + return Misskey.note.isPureRenote(note) ? note.renote : note; +} diff --git a/packages/frontend/src/utility/get-bg-color.ts b/packages/frontend/src/utility/get-bg-color.ts new file mode 100644 index 0000000000..ccf60b454f --- /dev/null +++ b/packages/frontend/src/utility/get-bg-color.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import tinycolor from 'tinycolor2'; + +export const getBgColor = (elem?: Element | null | undefined): string | null => { + if (elem == null) return null; + + const { backgroundColor: bg } = window.getComputedStyle(elem); + + if (bg && tinycolor(bg).getAlpha() !== 0) { + return bg; + } + + return getBgColor(elem.parentElement); +}; diff --git a/packages/frontend/src/utility/get-dom-node-or-null.ts b/packages/frontend/src/utility/get-dom-node-or-null.ts new file mode 100644 index 0000000000..fbf54675fd --- /dev/null +++ b/packages/frontend/src/utility/get-dom-node-or-null.ts @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const getNodeOrNull = (input: unknown): Node | null => { + if (input instanceof Node) return input; + return null; +}; + +export const getElementOrNull = (input: unknown): Element | null => { + if (input instanceof Element) return input; + return null; +}; + +export const getHTMLElementOrNull = (input: unknown): HTMLElement | null => { + if (input instanceof HTMLElement) return input; + return null; +}; diff --git a/packages/frontend/src/utility/get-drive-file-menu.ts b/packages/frontend/src/utility/get-drive-file-menu.ts new file mode 100644 index 0000000000..477043a342 --- /dev/null +++ b/packages/frontend/src/utility/get-drive-file-menu.ts @@ -0,0 +1,162 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { defineAsyncComponent } from 'vue'; +import type { MenuItem } from '@/types/menu.js'; +import { i18n } from '@/i18n.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { prefer } from '@/preferences.js'; + +function rename(file: Misskey.entities.DriveFile) { + os.inputText({ + title: i18n.ts.renameFile, + placeholder: i18n.ts.inputNewFileName, + default: file.name, + }).then(({ canceled, result: name }) => { + if (canceled) return; + misskeyApi('drive/files/update', { + fileId: file.id, + name: name, + }); + }); +} + +function describe(file: Misskey.entities.DriveFile) { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), { + default: file.comment ?? '', + file: file, + }, { + done: caption => { + misskeyApi('drive/files/update', { + fileId: file.id, + comment: caption.length === 0 ? null : caption, + }); + }, + closed: () => dispose(), + }); +} + +function move(file: Misskey.entities.DriveFile) { + os.selectDriveFolder(false).then(folder => { + misskeyApi('drive/files/update', { + fileId: file.id, + folderId: folder[0] ? folder[0].id : null, + }); + }); +} + +function toggleSensitive(file: Misskey.entities.DriveFile) { + misskeyApi('drive/files/update', { + fileId: file.id, + isSensitive: !file.isSensitive, + }).catch(err => { + os.alert({ + type: 'error', + title: i18n.ts.error, + text: err.message, + }); + }); +} + +function copyUrl(file: Misskey.entities.DriveFile) { + copyToClipboard(file.url); + os.success(); +} + +/* +function addApp() { + alert('not implemented yet'); +} +*/ +async function deleteFile(file: Misskey.entities.DriveFile) { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }), + }); + + if (canceled) return; + misskeyApi('drive/files/delete', { + fileId: file.id, + }); +} + +export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Misskey.entities.DriveFolder | null): MenuItem[] { + const isImage = file.type.startsWith('image/'); + + const menuItems: MenuItem[] = []; + + menuItems.push({ + type: 'link', + to: `/my/drive/file/${file.id}`, + text: i18n.ts._fileViewer.title, + icon: 'ti ti-info-circle', + }, { type: 'divider' }, { + text: i18n.ts.rename, + icon: 'ti ti-forms', + action: () => rename(file), + }, { + text: i18n.ts.move, + icon: 'ti ti-folder-symlink', + action: () => move(file), + }, { + text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive, + icon: file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation', + action: () => toggleSensitive(file), + }, { + text: i18n.ts.describeFile, + icon: 'ti ti-text-caption', + action: () => describe(file), + }); + + if (isImage) { + menuItems.push({ + text: i18n.ts.cropImage, + icon: 'ti ti-crop', + action: () => os.cropImage(file, { + aspectRatio: NaN, + uploadFolder: folder ? folder.id : folder, + }), + }); + } + + menuItems.push({ type: 'divider' }, { + text: i18n.ts.createNoteFromTheFile, + icon: 'ti ti-pencil', + action: () => os.post({ + initialFiles: [file], + }), + }, { + text: i18n.ts.copyUrl, + icon: 'ti ti-link', + action: () => copyUrl(file), + }, { + type: 'a', + href: file.url, + target: '_blank', + text: i18n.ts.download, + icon: 'ti ti-download', + download: file.name, + }, { type: 'divider' }, { + text: i18n.ts.delete, + icon: 'ti ti-trash', + danger: true, + action: () => deleteFile(file), + }); + + if (prefer.s.devMode) { + menuItems.push({ type: 'divider' }, { + icon: 'ti ti-id', + text: i18n.ts.copyFileId, + action: () => { + copyToClipboard(file.id); + }, + }); + } + + return menuItems; +} diff --git a/packages/frontend/src/utility/get-embed-code.ts b/packages/frontend/src/utility/get-embed-code.ts new file mode 100644 index 0000000000..9021520da8 --- /dev/null +++ b/packages/frontend/src/utility/get-embed-code.ts @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { defineAsyncComponent } from 'vue'; +import { v4 as uuid } from 'uuid'; +import type { EmbedParams, EmbeddableEntity } from '@@/js/embed-page.js'; +import { url } from '@@/js/config.js'; +import * as os from '@/os.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { defaultEmbedParams, embedRouteWithScrollbar } from '@@/js/embed-page.js'; + +const MOBILE_THRESHOLD = 500; + +/** + * パラメータを正規化する(埋め込みコード作成用) + * @param params パラメータ + * @returns 正規化されたパラメータ + */ +export function normalizeEmbedParams(params: EmbedParams): Record<string, string> { + // paramsのvalueをすべてstringに変換。undefinedやnullはプロパティごと消す + const normalizedParams: Record<string, string> = {}; + for (const key in params) { + // デフォルトの値と同じならparamsに含めない + if (params[key] == null || params[key] === defaultEmbedParams[key]) { + continue; + } + switch (typeof params[key]) { + case 'number': + normalizedParams[key] = params[key].toString(); + break; + case 'boolean': + normalizedParams[key] = params[key] ? 'true' : 'false'; + break; + default: + normalizedParams[key] = params[key]; + break; + } + } + return normalizedParams; +} + +/** + * 埋め込みコードを生成(iframe IDの発番もやる) + */ +export function getEmbedCode(path: string, params?: EmbedParams): string { + const iframeId = 'v1_' + uuid(); // 将来embed.jsのバージョンが上がったとき用にv1_を付けておく + + let paramString = ''; + if (params) { + const searchParams = new URLSearchParams(normalizeEmbedParams(params)); + paramString = searchParams.toString() === '' ? '' : '?' + searchParams.toString(); + } + + const iframeCode = [ + `<iframe src="${url + path + paramString}" data-misskey-embed-id="${iframeId}" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" style="border: none; width: 100%; max-width: 500px; height: 300px; color-scheme: light dark;"></iframe>`, + `<script defer src="${url}/embed.js"></script>`, + ]; + return iframeCode.join('\n'); +} + +/** + * 埋め込みコードを生成してコピーする(カスタマイズ機能つき) + * + * カスタマイズ機能がいらない場合(事前にパラメータを指定する場合)は getEmbedCode を直接使ってください + */ +export function genEmbedCode(entity: EmbeddableEntity, id: string, params?: EmbedParams) { + const _params = { ...params }; + + if (embedRouteWithScrollbar.includes(entity) && _params.maxHeight == null) { + _params.maxHeight = 700; + } + + // PCじゃない場合はコードカスタマイズ画面を出さずにそのままコピー + if (window.innerWidth < MOBILE_THRESHOLD) { + copyToClipboard(getEmbedCode(`/embed/${entity}/${id}`, _params)); + os.success(); + } else { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkEmbedCodeGenDialog.vue')), { + entity, + id, + params: _params, + }, { + closed: () => dispose(), + }); + } +} diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts new file mode 100644 index 0000000000..c95eaa20dd --- /dev/null +++ b/packages/frontend/src/utility/get-note-menu.ts @@ -0,0 +1,685 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineAsyncComponent } from 'vue'; +import type { Ref, ShallowRef } from 'vue'; +import * as Misskey from 'misskey-js'; +import { url } from '@@/js/config.js'; +import { claimAchievement } from './achievements.js'; +import type { MenuItem } from '@/types/menu.js'; +import { $i } from '@/account.js'; +import { i18n } from '@/i18n.js'; +import { instance } from '@/instance.js'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import { store, noteActions } from '@/store.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { getUserMenu } from '@/utility/get-user-menu.js'; +import { clipsCache, favoritedChannelsCache } from '@/cache.js'; +import MkRippleEffect from '@/components/MkRippleEffect.vue'; +import { isSupportShare } from '@/utility/navigator.js'; +import { getAppearNote } from '@/utility/get-appear-note.js'; +import { genEmbedCode } from '@/utility/get-embed-code.js'; +import { prefer } from '@/preferences.js'; + +export async function getNoteClipMenu(props: { + note: Misskey.entities.Note; + isDeleted: Ref<boolean>; + currentClip?: Misskey.entities.Clip; +}) { + function getClipName(clip: Misskey.entities.Clip) { + if ($i && clip.userId === $i.id && clip.notesCount != null) { + return `${clip.name} (${clip.notesCount}/${$i.policies.noteEachClipsLimit})`; + } else { + return clip.name; + } + } + + const appearNote = getAppearNote(props.note); + + const clips = await clipsCache.fetch(); + const menu: MenuItem[] = [...clips.map(clip => ({ + text: getClipName(clip), + action: () => { + claimAchievement('noteClipped1'); + os.promiseDialog( + misskeyApi('clips/add-note', { clipId: clip.id, noteId: appearNote.id }), + null, + async (err) => { + if (err.id === '734806c4-542c-463a-9311-15c512803965') { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.tsx.confirmToUnclipAlreadyClippedNote({ name: clip.name }), + }); + if (!confirm.canceled) { + os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }).then(() => { + clipsCache.set(clips.map(c => { + if (c.id === clip.id) { + return { + ...c, + notesCount: Math.max(0, ((c.notesCount ?? 0) - 1)), + }; + } else { + return c; + } + })); + }); + if (props.currentClip?.id === clip.id) props.isDeleted.value = true; + } + } else if (err.id === 'f0dba960-ff73-4615-8df4-d6ac5d9dc118') { + os.alert({ + type: 'error', + text: i18n.ts.clipNoteLimitExceeded, + }); + } else { + os.alert({ + type: 'error', + text: err.message + '\n' + err.id, + }); + } + }, + ).then(() => { + clipsCache.set(clips.map(c => { + if (c.id === clip.id) { + return { + ...c, + notesCount: (c.notesCount ?? 0) + 1, + }; + } else { + return c; + } + })); + }); + }, + })), { type: 'divider' }, { + icon: 'ti ti-plus', + text: i18n.ts.createNew, + action: async () => { + const { canceled, result } = await os.form(i18n.ts.createNewClip, { + name: { + type: 'string', + default: null, + label: i18n.ts.name, + }, + description: { + type: 'string', + required: false, + default: null, + multiline: true, + label: i18n.ts.description, + }, + isPublic: { + type: 'boolean', + label: i18n.ts.public, + default: false, + }, + }); + if (canceled) return; + + const clip = await os.apiWithDialog('clips/create', result); + + clipsCache.delete(); + + claimAchievement('noteClipped1'); + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); + }, + }]; + + return menu; +} + +export function getAbuseNoteMenu(note: Misskey.entities.Note, text: string): MenuItem { + return { + icon: 'ti ti-exclamation-circle', + text, + action: (): void => { + const localUrl = `${url}/notes/${note.id}`; + let noteInfo = ''; + if (note.url ?? note.uri != null) noteInfo = `Note: ${note.url ?? note.uri}\n`; + noteInfo += `Local Note: ${localUrl}\n`; + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: note.user, + initialComment: `${noteInfo}-----\n`, + }, { + closed: () => dispose(), + }); + }, + }; +} + +export function getCopyNoteLinkMenu(note: Misskey.entities.Note, text: string): MenuItem { + return { + icon: 'ti ti-link', + text, + action: (): void => { + copyToClipboard(`${url}/notes/${note.id}`); + os.success(); + }, + }; +} + +function getNoteEmbedCodeMenu(note: Misskey.entities.Note, text: string): MenuItem | undefined { + if (note.url != null || note.uri != null) return undefined; + if (['specified', 'followers'].includes(note.visibility)) return undefined; + + return { + icon: 'ti ti-code', + text, + action: (): void => { + genEmbedCode('notes', note.id); + }, + }; +} + +export function getNoteMenu(props: { + note: Misskey.entities.Note; + translation: Ref<Misskey.entities.NotesTranslateResponse | null>; + translating: Ref<boolean>; + isDeleted: Ref<boolean>; + currentClip?: Misskey.entities.Clip; +}) { + const appearNote = getAppearNote(props.note); + + const cleanups = [] as (() => void)[]; + + function del(): void { + os.confirm({ + type: 'warning', + text: i18n.ts.noteDeleteConfirm, + }).then(({ canceled }) => { + if (canceled) return; + + misskeyApi('notes/delete', { + noteId: appearNote.id, + }); + + if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) { + claimAchievement('noteDeletedWithin1min'); + } + }); + } + + function delEdit(): void { + os.confirm({ + type: 'warning', + text: i18n.ts.deleteAndEditConfirm, + }).then(({ canceled }) => { + if (canceled) return; + + misskeyApi('notes/delete', { + noteId: appearNote.id, + }); + + os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel }); + + if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60 && appearNote.userId === $i.id) { + claimAchievement('noteDeletedWithin1min'); + } + }); + } + + function toggleFavorite(favorite: boolean): void { + claimAchievement('noteFavorited1'); + os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { + noteId: appearNote.id, + }); + } + + function toggleThreadMute(mute: boolean): void { + os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { + noteId: appearNote.id, + }); + } + + function copyContent(): void { + copyToClipboard(appearNote.text); + os.success(); + } + + function togglePin(pin: boolean): void { + os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { + noteId: appearNote.id, + }, undefined, { + '72dab508-c64d-498f-8740-a8eec1ba385a': { + text: i18n.ts.pinLimitExceeded, + }, + }); + } + + async function unclip(): Promise<void> { + if (!props.currentClip) return; + os.apiWithDialog('clips/remove-note', { clipId: props.currentClip.id, noteId: appearNote.id }); + props.isDeleted.value = true; + } + + async function promote(): Promise<void> { + const { canceled, result: days } = await os.inputNumber({ + title: i18n.ts.numberOfDays, + }); + + if (canceled || days == null) return; + + os.apiWithDialog('admin/promo/create', { + noteId: appearNote.id, + expiresAt: Date.now() + (86400000 * days), + }); + } + + function share(): void { + navigator.share({ + title: i18n.tsx.noteOf({ user: appearNote.user.name ?? appearNote.user.username }), + text: appearNote.text ?? '', + url: `${url}/notes/${appearNote.id}`, + }); + } + + function openDetail(): void { + os.pageWindow(`/notes/${appearNote.id}`); + } + + async function translate(): Promise<void> { + if (props.translation.value != null) return; + props.translating.value = true; + const res = await misskeyApi('notes/translate', { + noteId: appearNote.id, + targetLang: miLocalStorage.getItem('lang') ?? navigator.language, + }); + props.translating.value = false; + props.translation.value = res; + } + + const menuItems: MenuItem[] = []; + + if ($i) { + const statePromise = misskeyApi('notes/state', { + noteId: appearNote.id, + }); + + if (props.currentClip?.userId === $i.id) { + menuItems.push({ + icon: 'ti ti-backspace', + text: i18n.ts.unclip, + danger: true, + action: unclip, + }, { type: 'divider' }); + } + + menuItems.push({ + icon: 'ti ti-info-circle', + text: i18n.ts.details, + action: openDetail, + }, { + icon: 'ti ti-copy', + text: i18n.ts.copyContent, + action: copyContent, + }, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink)); + + if (appearNote.url || appearNote.uri) { + menuItems.push({ + icon: 'ti ti-link', + text: i18n.ts.copyRemoteLink, + action: () => { + copyToClipboard(appearNote.url ?? appearNote.uri); + os.success(); + }, + }, { + icon: 'ti ti-external-link', + text: i18n.ts.showOnRemote, + action: () => { + window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener'); + }, + }); + } else { + menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode)); + } + + if (isSupportShare()) { + menuItems.push({ + icon: 'ti ti-share', + text: i18n.ts.share, + action: share, + }); + } + + if ($i.policies.canUseTranslator && instance.translatorAvailable) { + menuItems.push({ + icon: 'ti ti-language-hiragana', + text: i18n.ts.translate, + action: translate, + }); + } + + menuItems.push({ type: 'divider' }); + + menuItems.push(statePromise.then(state => state.isFavorited ? { + icon: 'ti ti-star-off', + text: i18n.ts.unfavorite, + action: () => toggleFavorite(false), + } : { + icon: 'ti ti-star', + text: i18n.ts.favorite, + action: () => toggleFavorite(true), + })); + + menuItems.push({ + type: 'parent', + icon: 'ti ti-paperclip', + text: i18n.ts.clip, + children: () => getNoteClipMenu(props), + }); + + menuItems.push(statePromise.then(state => state.isMutedThread ? { + icon: 'ti ti-message-off', + text: i18n.ts.unmuteThread, + action: () => toggleThreadMute(false), + } : { + icon: 'ti ti-message-off', + text: i18n.ts.muteThread, + action: () => toggleThreadMute(true), + })); + + if (appearNote.userId === $i.id) { + if (($i.pinnedNoteIds ?? []).includes(appearNote.id)) { + menuItems.push({ + icon: 'ti ti-pinned-off', + text: i18n.ts.unpin, + action: () => togglePin(false), + }); + } else { + menuItems.push({ + icon: 'ti ti-pin', + text: i18n.ts.pin, + action: () => togglePin(true), + }); + } + } + + menuItems.push({ + type: 'parent', + icon: 'ti ti-user', + text: i18n.ts.user, + children: async () => { + const user = appearNote.userId === $i?.id ? $i : await misskeyApi('users/show', { userId: appearNote.userId }); + const { menu, cleanup } = getUserMenu(user); + cleanups.push(cleanup); + return menu; + }, + }); + + if (appearNote.userId !== $i.id) { + menuItems.push({ type: 'divider' }); + menuItems.push(getAbuseNoteMenu(appearNote, i18n.ts.reportAbuse)); + } + + if (appearNote.channel && (appearNote.channel.userId === $i.id || $i.isModerator || $i.isAdmin)) { + menuItems.push({ type: 'divider' }); + menuItems.push({ + type: 'parent', + icon: 'ti ti-device-tv', + text: i18n.ts.channel, + children: async () => { + const channelChildMenu = [] as MenuItem[]; + + const channel = await misskeyApi('channels/show', { channelId: appearNote.channel!.id }); + + if (channel.pinnedNoteIds.includes(appearNote.id)) { + channelChildMenu.push({ + icon: 'ti ti-pinned-off', + text: i18n.ts.unpin, + action: () => os.apiWithDialog('channels/update', { + channelId: appearNote.channel!.id, + pinnedNoteIds: channel.pinnedNoteIds.filter(id => id !== appearNote.id), + }), + }); + } else { + channelChildMenu.push({ + icon: 'ti ti-pin', + text: i18n.ts.pin, + action: () => os.apiWithDialog('channels/update', { + channelId: appearNote.channel!.id, + pinnedNoteIds: [...channel.pinnedNoteIds, appearNote.id], + }), + }); + } + return channelChildMenu; + }, + }); + } + + if (appearNote.userId === $i.id || $i.isModerator || $i.isAdmin) { + menuItems.push({ type: 'divider' }); + if (appearNote.userId === $i.id) { + menuItems.push({ + icon: 'ti ti-edit', + text: i18n.ts.deleteAndEdit, + action: delEdit, + }); + } + menuItems.push({ + icon: 'ti ti-trash', + text: i18n.ts.delete, + danger: true, + action: del, + }); + } + } else { + menuItems.push({ + icon: 'ti ti-info-circle', + text: i18n.ts.details, + action: openDetail, + }, { + icon: 'ti ti-copy', + text: i18n.ts.copyContent, + action: copyContent, + }, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink)); + + if (appearNote.url || appearNote.uri) { + menuItems.push({ + icon: 'ti ti-link', + text: i18n.ts.copyRemoteLink, + action: () => { + copyToClipboard(appearNote.url ?? appearNote.uri); + os.success(); + }, + }, { + icon: 'ti ti-external-link', + text: i18n.ts.showOnRemote, + action: () => { + window.open(appearNote.url ?? appearNote.uri, '_blank', 'noopener'); + }, + }); + } else { + menuItems.push(getNoteEmbedCodeMenu(appearNote, i18n.ts.genEmbedCode)); + } + } + + if (noteActions.length > 0) { + menuItems.push({ type: 'divider' }); + + menuItems.push(...noteActions.map(action => ({ + icon: 'ti ti-plug', + text: action.title, + action: () => { + action.handler(appearNote); + }, + }))); + } + + if (prefer.s.devMode) { + menuItems.push({ type: 'divider' }, { + icon: 'ti ti-id', + text: i18n.ts.copyNoteId, + action: () => { + copyToClipboard(appearNote.id); + os.success(); + }, + }); + } + + const cleanup = () => { + if (_DEV_) console.log('note menu cleanup', cleanups); + for (const cl of cleanups) { + cl(); + } + }; + + return { + menu: menuItems, + cleanup, + }; +} + +type Visibility = (typeof Misskey.noteVisibilities)[number]; + +function smallerVisibility(a: Visibility, b: Visibility): Visibility { + if (a === 'specified' || b === 'specified') return 'specified'; + if (a === 'followers' || b === 'followers') return 'followers'; + if (a === 'home' || b === 'home') return 'home'; + // if (a === 'public' || b === 'public') + return 'public'; +} + +export function getRenoteMenu(props: { + note: Misskey.entities.Note; + renoteButton: ShallowRef<HTMLElement | undefined>; + mock?: boolean; +}) { + const appearNote = getAppearNote(props.note); + + const channelRenoteItems: MenuItem[] = []; + const normalRenoteItems: MenuItem[] = []; + const normalExternalChannelRenoteItems: MenuItem[] = []; + + if (appearNote.channel) { + channelRenoteItems.push(...[{ + text: i18n.ts.inChannelRenote, + icon: 'ti ti-repeat', + action: () => { + const el = props.renoteButton.value; + if (el && prefer.s.animation) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + const { dispose } = os.popup(MkRippleEffect, { x, y }, { + end: () => dispose(), + }); + } + + if (!props.mock) { + misskeyApi('notes/create', { + renoteId: appearNote.id, + channelId: appearNote.channelId, + }).then(() => { + os.toast(i18n.ts.renoted); + }); + } + }, + }, { + text: i18n.ts.inChannelQuote, + icon: 'ti ti-quote', + action: () => { + if (!props.mock) { + os.post({ + renote: appearNote, + channel: appearNote.channel, + }); + } + }, + }]); + } + + if (!appearNote.channel || appearNote.channel.allowRenoteToExternal) { + normalRenoteItems.push(...[{ + text: i18n.ts.renote, + icon: 'ti ti-repeat', + action: () => { + const el = props.renoteButton.value; + if (el && prefer.s.animation) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + const { dispose } = os.popup(MkRippleEffect, { x, y }, { + end: () => dispose(), + }); + } + + const configuredVisibility = prefer.s.rememberNoteVisibility ? store.state.visibility : prefer.s.defaultNoteVisibility; + const localOnly = prefer.s.rememberNoteVisibility ? store.state.localOnly : prefer.s.defaultNoteLocalOnly; + + let visibility = appearNote.visibility; + visibility = smallerVisibility(visibility, configuredVisibility); + if (appearNote.channel?.isSensitive) { + visibility = smallerVisibility(visibility, 'home'); + } + + if (!props.mock) { + misskeyApi('notes/create', { + localOnly, + visibility, + renoteId: appearNote.id, + }).then(() => { + os.toast(i18n.ts.renoted); + }); + } + }, + }, (props.mock) ? undefined : { + text: i18n.ts.quote, + icon: 'ti ti-quote', + action: () => { + os.post({ + renote: appearNote, + }); + }, + }]); + + normalExternalChannelRenoteItems.push({ + type: 'parent', + icon: 'ti ti-repeat', + text: appearNote.channel ? i18n.ts.renoteToOtherChannel : i18n.ts.renoteToChannel, + children: async () => { + const channels = await favoritedChannelsCache.fetch(); + return channels.filter((channel) => { + if (!appearNote.channelId) return true; + return channel.id !== appearNote.channelId; + }).map((channel) => ({ + text: channel.name, + action: () => { + const el = props.renoteButton.value; + if (el && prefer.s.animation) { + const rect = el.getBoundingClientRect(); + const x = rect.left + (el.offsetWidth / 2); + const y = rect.top + (el.offsetHeight / 2); + const { dispose } = os.popup(MkRippleEffect, { x, y }, { + end: () => dispose(), + }); + } + + if (!props.mock) { + misskeyApi('notes/create', { + renoteId: appearNote.id, + channelId: channel.id, + }).then(() => { + os.toast(i18n.tsx.renotedToX({ name: channel.name })); + }); + } + }, + })); + }, + }); + } + + const renoteItems = [ + ...normalRenoteItems, + ...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [{ type: 'divider' }] as MenuItem[] : [], + ...channelRenoteItems, + ...(normalExternalChannelRenoteItems.length > 0 && (normalRenoteItems.length > 0 || channelRenoteItems.length > 0)) ? [{ type: 'divider' }] as MenuItem[] : [], + ...normalExternalChannelRenoteItems, + ]; + + return { + menu: renoteItems, + }; +} diff --git a/packages/frontend/src/utility/get-note-summary.ts b/packages/frontend/src/utility/get-note-summary.ts new file mode 100644 index 0000000000..6fd9947ac1 --- /dev/null +++ b/packages/frontend/src/utility/get-note-summary.ts @@ -0,0 +1,64 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { i18n } from '@/i18n.js'; + +/** + * 投稿を表す文字列を取得します。 + * @param {*} note (packされた)投稿 + */ +export const getNoteSummary = (note?: Misskey.entities.Note | null): string => { + if (note == null) { + return ''; + } + + if (note.deletedAt) { + return `(${i18n.ts.deletedNote})`; + } + + if (note.isHidden) { + return `(${i18n.ts.invisibleNote})`; + } + + let summary = ''; + + // 本文 + if (note.cw != null) { + summary += note.cw; + } else { + summary += note.text ? note.text : ''; + } + + // ファイルが添付されているとき + if ((note.files || []).length !== 0) { + summary += ` (${i18n.tsx.withNFiles({ n: note.files.length })})`; + } + + // 投票が添付されているとき + if (note.poll) { + summary += ` (${i18n.ts.poll})`; + } + + // 返信のとき + if (note.replyId) { + if (note.reply) { + summary += `\n\nRE: ${getNoteSummary(note.reply)}`; + } else { + summary += '\n\nRE: ...'; + } + } + + // Renoteのとき + if (note.renoteId) { + if (note.renote) { + summary += `\n\nRN: ${getNoteSummary(note.renote)}`; + } else { + summary += '\n\nRN: ...'; + } + } + + return summary.trim(); +}; diff --git a/packages/frontend/src/utility/get-user-menu.ts b/packages/frontend/src/utility/get-user-menu.ts new file mode 100644 index 0000000000..d739976cb1 --- /dev/null +++ b/packages/frontend/src/utility/get-user-menu.ts @@ -0,0 +1,441 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { toUnicode } from 'punycode.js'; +import { defineAsyncComponent, ref, watch } from 'vue'; +import * as Misskey from 'misskey-js'; +import { host, url } from '@@/js/config.js'; +import type { IRouter } from '@/nirax.js'; +import type { MenuItem } from '@/types/menu.js'; +import { i18n } from '@/i18n.js'; +import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { userActions } from '@/store.js'; +import { $i, iAmModerator } from '@/account.js'; +import { notesSearchAvailable, canSearchNonLocalNotes } from '@/utility/check-permissions.js'; +import { antennasCache, rolesCache, userListsCache } from '@/cache.js'; +import { mainRouter } from '@/router/main.js'; +import { genEmbedCode } from '@/utility/get-embed-code.js'; +import { prefer } from '@/preferences.js'; + +export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) { + const meId = $i ? $i.id : null; + + const cleanups = [] as (() => void)[]; + + async function toggleMute() { + if (user.isMuted) { + os.apiWithDialog('mute/delete', { + userId: user.id, + }).then(() => { + user.isMuted = false; + }); + } else { + const { canceled, result: period } = await os.select({ + title: i18n.ts.mutePeriod, + items: [{ + value: 'indefinitely', text: i18n.ts.indefinitely, + }, { + value: 'tenMinutes', text: i18n.ts.tenMinutes, + }, { + value: 'oneHour', text: i18n.ts.oneHour, + }, { + value: 'oneDay', text: i18n.ts.oneDay, + }, { + value: 'oneWeek', text: i18n.ts.oneWeek, + }], + default: 'indefinitely', + }); + if (canceled) return; + + const expiresAt = period === 'indefinitely' ? null + : period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10) + : period === 'oneHour' ? Date.now() + (1000 * 60 * 60) + : period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24) + : period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7) + : null; + + os.apiWithDialog('mute/create', { + userId: user.id, + expiresAt, + }).then(() => { + user.isMuted = true; + }); + } + } + + async function toggleRenoteMute() { + os.apiWithDialog(user.isRenoteMuted ? 'renote-mute/delete' : 'renote-mute/create', { + userId: user.id, + }).then(() => { + user.isRenoteMuted = !user.isRenoteMuted; + }); + } + + async function toggleBlock() { + if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return; + + os.apiWithDialog(user.isBlocking ? 'blocking/delete' : 'blocking/create', { + userId: user.id, + }).then(() => { + user.isBlocking = !user.isBlocking; + }); + } + + async function toggleNotify() { + os.apiWithDialog('following/update', { + userId: user.id, + notify: user.notify === 'normal' ? 'none' : 'normal', + }).then(() => { + user.notify = user.notify === 'normal' ? 'none' : 'normal'; + }); + } + + function reportAbuse() { + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: user, + }, { + closed: () => dispose(), + }); + } + + async function getConfirmed(text: string): Promise<boolean> { + const confirm = await os.confirm({ + type: 'warning', + title: 'confirm', + text, + }); + + return !confirm.canceled; + } + + async function userInfoUpdate() { + os.apiWithDialog('federation/update-remote-user', { + userId: user.id, + }); + } + + async function invalidateFollow() { + if (!await getConfirmed(i18n.ts.breakFollowConfirm)) return; + + os.apiWithDialog('following/invalidate', { + userId: user.id, + }).then(() => { + user.isFollowed = !user.isFollowed; + }); + } + + async function editMemo(): Promise<void> { + const userDetailed = await misskeyApi('users/show', { + userId: user.id, + }); + const { canceled, result } = await os.form(i18n.ts.editMemo, { + memo: { + type: 'string', + required: true, + multiline: true, + label: i18n.ts.memo, + default: userDetailed.memo, + }, + }); + if (canceled) return; + + os.apiWithDialog('users/update-memo', { + memo: result.memo, + userId: user.id, + }); + } + + const menuItems: MenuItem[] = []; + + menuItems.push({ + icon: 'ti ti-at', + text: i18n.ts.copyUsername, + action: () => { + copyToClipboard(`@${user.username}@${user.host ?? host}`); + }, + }); + + if (notesSearchAvailable && (user.host == null || canSearchNonLocalNotes)) { + menuItems.push({ + icon: 'ti ti-search', + text: i18n.ts.searchThisUsersNotes, + action: () => { + router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`); + }, + }); + } + + if (iAmModerator) { + menuItems.push({ + icon: 'ti ti-user-exclamation', + text: i18n.ts.moderation, + action: () => { + router.push(`/admin/user/${user.id}`); + }, + }); + } + + menuItems.push({ + icon: 'ti ti-rss', + text: i18n.ts.copyRSS, + action: () => { + copyToClipboard(`${user.host ?? host}/@${user.username}.atom`); + }, + }); + + if (user.host != null && user.url != null) { + menuItems.push({ + icon: 'ti ti-external-link', + text: i18n.ts.showOnRemote, + action: () => { + if (user.url == null) return; + window.open(user.url, '_blank', 'noopener'); + }, + }); + } else { + menuItems.push({ + icon: 'ti ti-code', + text: i18n.ts.genEmbedCode, + type: 'parent', + children: [{ + text: i18n.ts.noteOfThisUser, + action: () => { + genEmbedCode('user-timeline', user.id); + }, + }], // TODO: ユーザーカードの埋め込みなど + }); + } + + menuItems.push({ + icon: 'ti ti-share', + text: i18n.ts.copyProfileUrl, + action: () => { + const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; + copyToClipboard(`${url}/${canonical}`); + }, + }); + + if ($i) { + menuItems.push({ + icon: 'ti ti-mail', + text: i18n.ts.sendMessage, + action: () => { + const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${user.host}`; + os.post({ specified: user, initialText: `${canonical} ` }); + }, + }, { type: 'divider' }, { + icon: 'ti ti-pencil', + text: i18n.ts.editMemo, + action: editMemo, + }, { + type: 'parent', + icon: 'ti ti-list', + text: i18n.ts.addToList, + children: async () => { + const lists = await userListsCache.fetch(); + return lists.map(list => { + const isListed = ref(list.userIds?.includes(user.id) ?? false); + cleanups.push(watch(isListed, () => { + if (isListed.value) { + os.apiWithDialog('users/lists/push', { + listId: list.id, + userId: user.id, + }).then(() => { + list.userIds?.push(user.id); + }); + } else { + os.apiWithDialog('users/lists/pull', { + listId: list.id, + userId: user.id, + }).then(() => { + list.userIds?.splice(list.userIds.indexOf(user.id), 1); + }); + } + })); + + return { + type: 'switch', + text: list.name, + ref: isListed, + }; + }); + }, + }, { + type: 'parent', + icon: 'ti ti-antenna', + text: i18n.ts.addToAntenna, + children: async () => { + const antennas = await antennasCache.fetch(); + const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`; + return antennas.filter((a) => a.src === 'users').map(antenna => ({ + text: antenna.name, + action: async () => { + await os.apiWithDialog('antennas/update', { + antennaId: antenna.id, + name: antenna.name, + keywords: antenna.keywords, + excludeKeywords: antenna.excludeKeywords, + src: antenna.src, + userListId: antenna.userListId, + users: [...antenna.users, canonical], + caseSensitive: antenna.caseSensitive, + withReplies: antenna.withReplies, + withFile: antenna.withFile, + notify: antenna.notify, + }); + antennasCache.delete(); + }, + })); + }, + }); + } + + if ($i && meId !== user.id) { + if (iAmModerator) { + menuItems.push({ + type: 'parent', + icon: 'ti ti-badges', + text: i18n.ts.roles, + children: async () => { + const roles = await rolesCache.fetch(); + + return roles.filter(r => r.target === 'manual').map(r => ({ + text: r.name, + action: async () => { + const { canceled, result: period } = await os.select({ + title: i18n.ts.period + ': ' + r.name, + items: [{ + value: 'indefinitely', text: i18n.ts.indefinitely, + }, { + value: 'oneHour', text: i18n.ts.oneHour, + }, { + value: 'oneDay', text: i18n.ts.oneDay, + }, { + value: 'oneWeek', text: i18n.ts.oneWeek, + }, { + value: 'oneMonth', text: i18n.ts.oneMonth, + }], + default: 'indefinitely', + }); + if (canceled) return; + + const expiresAt = period === 'indefinitely' ? null + : period === 'oneHour' ? Date.now() + (1000 * 60 * 60) + : period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24) + : period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7) + : period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30) + : null; + + os.apiWithDialog('admin/roles/assign', { roleId: r.id, userId: user.id, expiresAt }); + }, + })); + }, + }); + } + + // フォローしたとしても user.isFollowing はリアルタイム更新されないので不便なため + //if (user.isFollowing) { + const withRepliesRef = ref(user.withReplies ?? false); + + menuItems.push({ + type: 'switch', + icon: 'ti ti-messages', + text: i18n.ts.showRepliesToOthersInTimeline, + ref: withRepliesRef, + }, { + icon: user.notify === 'none' ? 'ti ti-bell' : 'ti ti-bell-off', + text: user.notify === 'none' ? i18n.ts.notifyNotes : i18n.ts.unnotifyNotes, + action: toggleNotify, + }); + + watch(withRepliesRef, (withReplies) => { + misskeyApi('following/update', { + userId: user.id, + withReplies, + }).then(() => { + user.withReplies = withReplies; + }); + }); + //} + + menuItems.push({ type: 'divider' }, { + icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off', + text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute, + action: toggleMute, + }, { + icon: user.isRenoteMuted ? 'ti ti-repeat' : 'ti ti-repeat-off', + text: user.isRenoteMuted ? i18n.ts.renoteUnmute : i18n.ts.renoteMute, + action: toggleRenoteMute, + }, { + icon: 'ti ti-ban', + text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block, + action: toggleBlock, + }); + + if (user.isFollowed) { + menuItems.push({ + icon: 'ti ti-link-off', + text: i18n.ts.breakFollow, + action: invalidateFollow, + }); + } + + menuItems.push({ type: 'divider' }, { + icon: 'ti ti-exclamation-circle', + text: i18n.ts.reportAbuse, + action: reportAbuse, + }); + } + + if (user.host !== null) { + menuItems.push({ type: 'divider' }, { + icon: 'ti ti-refresh', + text: i18n.ts.updateRemoteUser, + action: userInfoUpdate, + }); + } + + if (prefer.s.devMode) { + menuItems.push({ type: 'divider' }, { + icon: 'ti ti-id', + text: i18n.ts.copyUserId, + action: () => { + copyToClipboard(user.id); + }, + }); + } + + if ($i && meId === user.id) { + menuItems.push({ type: 'divider' }, { + icon: 'ti ti-pencil', + text: i18n.ts.editProfile, + action: () => { + router.push('/settings/profile'); + }, + }); + } + + if (userActions.length > 0) { + menuItems.push({ type: 'divider' }, ...userActions.map(action => ({ + icon: 'ti ti-plug', + text: action.title, + action: () => { + action.handler(user); + }, + }))); + } + + return { + menu: menuItems, + cleanup: () => { + if (_DEV_) console.log('user menu cleanup', cleanups); + for (const cl of cleanups) { + cl(); + } + }, + }; +} diff --git a/packages/frontend/src/utility/get-user-name.ts b/packages/frontend/src/utility/get-user-name.ts new file mode 100644 index 0000000000..56e91abba0 --- /dev/null +++ b/packages/frontend/src/utility/get-user-name.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export default function(user: { name?: string | null, username: string }): string { + return user.name === '' ? user.username : user.name ?? user.username; +} diff --git a/packages/frontend/src/utility/hotkey.ts b/packages/frontend/src/utility/hotkey.ts new file mode 100644 index 0000000000..fe62139a74 --- /dev/null +++ b/packages/frontend/src/utility/hotkey.ts @@ -0,0 +1,172 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { getHTMLElementOrNull } from "@/utility/get-dom-node-or-null.js"; + +//#region types +export type Keymap = Record<string, CallbackFunction | CallbackObject>; + +type CallbackFunction = (ev: KeyboardEvent) => unknown; + +type CallbackObject = { + callback: CallbackFunction; + allowRepeat?: boolean; +}; + +type Pattern = { + which: string[]; + ctrl: boolean; + alt: boolean; + shift: boolean; +}; + +type Action = { + patterns: Pattern[]; + callback: CallbackFunction; + options: Required<Omit<CallbackObject, 'callback'>>; +}; +//#endregion + +//#region consts +const KEY_ALIASES = { + 'esc': 'Escape', + 'enter': 'Enter', + 'space': ' ', + 'up': 'ArrowUp', + 'down': 'ArrowDown', + 'left': 'ArrowLeft', + 'right': 'ArrowRight', + 'plus': ['+', ';'], +}; + +const MODIFIER_KEYS = ['ctrl', 'alt', 'shift']; + +const IGNORE_ELEMENTS = ['input', 'textarea']; +//#endregion + +//#region store +let latestHotkey: Pattern & { callback: CallbackFunction } | null = null; +//#endregion + +//#region impl +export const makeHotkey = (keymap: Keymap) => { + const actions = parseKeymap(keymap); + return (ev: KeyboardEvent) => { + if ('pswp' in window && window.pswp != null) return; + if (document.activeElement != null) { + if (IGNORE_ELEMENTS.includes(document.activeElement.tagName.toLowerCase())) return; + if (getHTMLElementOrNull(document.activeElement)?.isContentEditable) return; + } + for (const action of actions) { + if (matchPatterns(ev, action)) { + ev.preventDefault(); + ev.stopPropagation(); + action.callback(ev); + storePattern(ev, action.callback); + } + } + }; +}; + +const parseKeymap = (keymap: Keymap) => { + return Object.entries(keymap).map(([rawPatterns, rawCallback]) => { + const patterns = parsePatterns(rawPatterns); + const callback = parseCallback(rawCallback); + const options = parseOptions(rawCallback); + return { patterns, callback, options } as const satisfies Action; + }); +}; + +const parsePatterns = (rawPatterns: keyof Keymap) => { + return rawPatterns.split('|').map(part => { + const keys = part.split('+').map(trimLower); + const which = parseKeyCode(keys.findLast(x => !MODIFIER_KEYS.includes(x))); + const ctrl = keys.includes('ctrl'); + const alt = keys.includes('alt'); + const shift = keys.includes('shift'); + return { which, ctrl, alt, shift } as const satisfies Pattern; + }); +}; + +const parseCallback = (rawCallback: Keymap[keyof Keymap]) => { + if (typeof rawCallback === 'object') { + return rawCallback.callback; + } + return rawCallback; +}; + +const parseOptions = (rawCallback: Keymap[keyof Keymap]) => { + const defaultOptions = { + allowRepeat: false, + } as const satisfies Action['options']; + if (typeof rawCallback === 'object') { + const { callback, ...rawOptions } = rawCallback; + const options = { ...defaultOptions, ...rawOptions }; + return { ...options } as const satisfies Action['options']; + } + return { ...defaultOptions } as const satisfies Action['options']; +}; + +const matchPatterns = (ev: KeyboardEvent, action: Action) => { + const { patterns, options, callback } = action; + if (ev.repeat && !options.allowRepeat) return false; + const key = ev.key.toLowerCase(); + return patterns.some(({ which, ctrl, shift, alt }) => { + if ( + options.allowRepeat === false && + latestHotkey != null && + latestHotkey.which.includes(key) && + latestHotkey.ctrl === ctrl && + latestHotkey.alt === alt && + latestHotkey.shift === shift && + latestHotkey.callback === callback + ) { + return false; + } + if (!which.includes(key)) return false; + if (ctrl !== (ev.ctrlKey || ev.metaKey)) return false; + if (alt !== ev.altKey) return false; + if (shift !== ev.shiftKey) return false; + return true; + }); +}; + +let lastHotKeyStoreTimer: number | null = null; + +const storePattern = (ev: KeyboardEvent, callback: CallbackFunction) => { + if (lastHotKeyStoreTimer != null) { + clearTimeout(lastHotKeyStoreTimer); + } + + latestHotkey = { + which: [ev.key.toLowerCase()], + ctrl: ev.ctrlKey || ev.metaKey, + alt: ev.altKey, + shift: ev.shiftKey, + callback, + }; + + lastHotKeyStoreTimer = window.setTimeout(() => { + latestHotkey = null; + }, 500); +}; + +const parseKeyCode = (input?: string | null) => { + if (input == null) return []; + const raw = getValueByKey(KEY_ALIASES, input); + if (raw == null) return [input]; + if (typeof raw === 'string') return [trimLower(raw)]; + return raw.map(trimLower); +}; + +const getValueByKey = < + T extends Record<keyof any, unknown>, + K extends keyof T | keyof any, + R extends K extends keyof T ? T[K] : T[keyof T] | undefined, +>(obj: T, key: K) => { + return obj[key] as R; +}; + +const trimLower = (str: string) => str.trim().toLowerCase(); +//#endregion diff --git a/packages/frontend/src/utility/idb-proxy.ts b/packages/frontend/src/utility/idb-proxy.ts new file mode 100644 index 0000000000..20f51660c7 --- /dev/null +++ b/packages/frontend/src/utility/idb-proxy.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// FirefoxのプライベートモードなどではindexedDBが使用不可能なので、 +// indexedDBが使えない環境ではlocalStorageを使う +import { + get as iget, + set as iset, + del as idel, +} from 'idb-keyval'; +import { miLocalStorage } from '@/local-storage.js'; + +const PREFIX = 'idbfallback::'; + +let idbAvailable = typeof window !== 'undefined' ? !!(window.indexedDB && typeof window.indexedDB.open === 'function') : true; + +// iframe.contentWindow.indexedDB.deleteDatabase() がchromeのバグで使用できないため、indexedDBを無効化している。 +// バグが治って再度有効化するのであれば、cypressのコマンド内のコメントアウトを外すこと +// see https://github.com/misskey-dev/misskey/issues/13605#issuecomment-2053652123 +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error +if (window.Cypress) { + idbAvailable = false; + console.log('Cypress detected. It will use localStorage.'); +} + +if (idbAvailable) { + await iset('idb-test', 'test') + .catch(err => { + console.error('idb error', err); + console.error('indexedDB is unavailable. It will use localStorage.'); + idbAvailable = false; + }); +} else { + console.error('indexedDB is unavailable. It will use localStorage.'); +} + +export async function get(key: string) { + if (idbAvailable) return iget(key); + return miLocalStorage.getItemAsJson(`${PREFIX}${key}`); +} + +export async function set(key: string, val: any) { + if (idbAvailable) return iset(key, val); + return miLocalStorage.setItemAsJson(`${PREFIX}${key}`, val); +} + +export async function del(key: string) { + if (idbAvailable) return idel(key); + return miLocalStorage.removeItem(`${PREFIX}${key}`); +} diff --git a/packages/frontend/src/utility/idle-render.ts b/packages/frontend/src/utility/idle-render.ts new file mode 100644 index 0000000000..6adfedcb9f --- /dev/null +++ b/packages/frontend/src/utility/idle-render.ts @@ -0,0 +1,60 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const requestIdleCallback: typeof globalThis.requestIdleCallback = globalThis.requestIdleCallback ?? ((callback) => { + const start = performance.now(); + const timeoutId = setTimeout(() => { + callback({ + didTimeout: false, // polyfill でタイムアウト発火することはない + timeRemaining() { + const diff = performance.now() - start; + return Math.max(0, 50 - diff); // <https://www.w3.org/TR/requestidlecallback/#idle-periods> + }, + }); + }); + return timeoutId; +}); +const cancelIdleCallback: typeof globalThis.cancelIdleCallback = globalThis.cancelIdleCallback ?? ((timeoutId) => { + clearTimeout(timeoutId); +}); + +class IdlingRenderScheduler { + #renderers: Set<FrameRequestCallback>; + #rafId: number; + #ricId: number; + + constructor() { + this.#renderers = new Set(); + this.#rafId = 0; + this.#ricId = requestIdleCallback((deadline) => this.#schedule(deadline)); + } + + #schedule(deadline: IdleDeadline): void { + if (deadline.timeRemaining()) { + this.#rafId = requestAnimationFrame((time) => { + for (const renderer of this.#renderers) { + renderer(time); + } + }); + } + this.#ricId = requestIdleCallback((arg) => this.#schedule(arg)); + } + + add(renderer: FrameRequestCallback): void { + this.#renderers.add(renderer); + } + + delete(renderer: FrameRequestCallback): void { + this.#renderers.delete(renderer); + } + + dispose(): void { + this.#renderers.clear(); + cancelAnimationFrame(this.#rafId); + cancelIdleCallback(this.#ricId); + } +} + +export const defaultIdlingRenderScheduler = new IdlingRenderScheduler(); diff --git a/packages/frontend/src/utility/init-chart.ts b/packages/frontend/src/utility/init-chart.ts new file mode 100644 index 0000000000..037b0d9567 --- /dev/null +++ b/packages/frontend/src/utility/init-chart.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + DoughnutController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import gradient from 'chartjs-plugin-gradient'; +import zoomPlugin from 'chartjs-plugin-zoom'; +import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; +import { store } from '@/store.js'; +import 'chartjs-adapter-date-fns'; + +export function initChart() { + Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + DoughnutController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + MatrixController, MatrixElement, + zoomPlugin, + gradient, + ); + + // フォントカラー + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-fg'); + + Chart.defaults.borderColor = store.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + + Chart.defaults.animation = false; +} diff --git a/packages/frontend/src/utility/initialize-sw.ts b/packages/frontend/src/utility/initialize-sw.ts new file mode 100644 index 0000000000..867ebf19ed --- /dev/null +++ b/packages/frontend/src/utility/initialize-sw.ts @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { lang } from '@@/js/config.js'; + +export async function initializeSw() { + if (!('serviceWorker' in navigator)) return; + + navigator.serviceWorker.register('/sw.js', { scope: '/', type: 'classic' }); + navigator.serviceWorker.ready.then(registration => { + registration.active?.postMessage({ + msg: 'initialize', + lang, + }); + }); +} diff --git a/packages/frontend/src/utility/intl-const.ts b/packages/frontend/src/utility/intl-const.ts new file mode 100644 index 0000000000..385f59ec39 --- /dev/null +++ b/packages/frontend/src/utility/intl-const.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { lang } from '@@/js/config.js'; + +export const versatileLang = (lang ?? 'ja-JP').replace('ja-KS', 'ja-JP'); + +let _dateTimeFormat: Intl.DateTimeFormat; +try { + _dateTimeFormat = new Intl.DateTimeFormat(versatileLang, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }); +} catch (err) { + console.warn(err); + if (_DEV_) console.log('[Intl] Fallback to en-US'); + + // Fallback to en-US + _dateTimeFormat = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + }); +} +export const dateTimeFormat = _dateTimeFormat; + +export const timeZone = dateTimeFormat.resolvedOptions().timeZone; + +export const hemisphere = /^(australia|pacific|antarctica|indian)\//i.test(timeZone) ? 'S' : 'N'; + +let _numberFormat: Intl.NumberFormat; +try { + _numberFormat = new Intl.NumberFormat(versatileLang); +} catch (err) { + console.warn(err); + if (_DEV_) console.log('[Intl] Fallback to en-US'); + + // Fallback to en-US + _numberFormat = new Intl.NumberFormat('en-US'); +} +export const numberFormat = _numberFormat; diff --git a/packages/frontend/src/utility/intl-string.ts b/packages/frontend/src/utility/intl-string.ts new file mode 100644 index 0000000000..a5b5bbb592 --- /dev/null +++ b/packages/frontend/src/utility/intl-string.ts @@ -0,0 +1,97 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { versatileLang } from '@@/js/intl-const.js'; +import type { toHiragana as toHiraganaType } from 'wanakana'; + +let toHiragana: typeof toHiraganaType = (str?: string) => str ?? ''; +let isWanakanaLoaded = false; + +/** + * ローマ字変換のセットアップ(日本語以外の環境で読み込まないのでlazy-loading) + * + * ここの比較系関数を使う際は事前に呼び出す必要がある + */ +export async function initIntlString(forceWanakana = false) { + if ((!versatileLang.includes('ja') && !forceWanakana) || isWanakanaLoaded) return; + const { toHiragana: _toHiragana } = await import('wanakana'); + toHiragana = _toHiragana; + isWanakanaLoaded = true; +} + +/** + * - 全角英数字を半角に + * - 半角カタカナを全角に + * - 濁点・半濁点がリガチャになっている(例: `か` + `゛` )ひらがな・カタカナを結合 + * - 異体字を正規化 + * - 小文字に揃える + * - 文字列のトリム + */ +export function normalizeString(str: string) { + const segmenter = new Intl.Segmenter(versatileLang, { granularity: 'grapheme' }); + return [...segmenter.segment(str)].map(({ segment }) => segment.normalize('NFKC')).join('').toLowerCase().trim(); +} + +// https://qiita.com/non-caffeine/items/77360dda05c8ce510084 +const hyphens = [ + 0x002d, // hyphen-minus + 0x02d7, // modifier letter minus sign + 0x1173, // hangul jongseong eu + 0x1680, // ogham space mark + 0x1b78, // balinese musical symbol left-hand open pang + 0x2010, // hyphen + 0x2011, // non-breaking hyphen + 0x2012, // figure dash + 0x2013, // en dash + 0x2014, // em dash + 0x2015, // horizontal bar + 0x2043, // hyphen bullet + 0x207b, // superscript minus + 0x2212, // minus sign + 0x25ac, // black rectangle + 0x2500, // box drawings light horizontal + 0x2501, // box drawings heavy horizontal + 0x2796, // heavy minus sign + 0x30fc, // katakana-hiragana prolonged sound mark + 0x3161, // hangul letter eu + 0xfe58, // small em dash + 0xfe63, // small hyphen-minus + 0xff0d, // fullwidth hyphen-minus + 0xff70, // halfwidth katakana-hiragana prolonged sound mark + 0x10110, // aegean number ten + 0x10191, // roman uncia sign +]; + +const hyphensCodePoints = hyphens.map(code => `\\u{${code.toString(16).padStart(4, '0')}}`); + +/** ハイフンを統一(ローマ字半角入力時に`ー`と`-`が判定できない問題の調整) */ +export function normalizeHyphens(str: string) { + return str.replace(new RegExp(`[${hyphensCodePoints.join('')}]`, 'ug'), '\u002d'); +} + +/** + * `normalizeString` に加えて、カタカナ・ローマ字をひらがなに揃え、ハイフンを統一 + * + * (ローマ字じゃないものもローマ字として認識され変換されるので、文字列比較の際は `normalizeString` を併用する必要あり) + */ +export function normalizeStringWithHiragana(str: string) { + return normalizeHyphens(toHiragana(normalizeString(str), { convertLongVowelMark: false })); +} + +/** aとbが同じかどうか */ +export function compareStringEquals(a: string, b: string) { + return ( + normalizeString(a) === normalizeString(b) || + normalizeStringWithHiragana(a) === normalizeStringWithHiragana(b) + ); +} + +/** baseにqueryが含まれているかどうか */ +export function compareStringIncludes(base: string, query: string) { + return ( + normalizeString(base).includes(normalizeString(query)) || + normalizeStringWithHiragana(base).includes(normalizeStringWithHiragana(query)) + ); +} diff --git a/packages/frontend/src/utility/is-device-darkmode.ts b/packages/frontend/src/utility/is-device-darkmode.ts new file mode 100644 index 0000000000..4f487c7cb9 --- /dev/null +++ b/packages/frontend/src/utility/is-device-darkmode.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function isDeviceDarkmode() { + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} diff --git a/packages/frontend/src/utility/isFfVisibleForMe.ts b/packages/frontend/src/utility/isFfVisibleForMe.ts new file mode 100644 index 0000000000..e28e5725bc --- /dev/null +++ b/packages/frontend/src/utility/isFfVisibleForMe.ts @@ -0,0 +1,24 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { $i } from '@/account.js'; + +export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): boolean { + if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true; + + if (user.followingVisibility === 'private') return false; + if (user.followingVisibility === 'followers' && !user.isFollowing) return false; + + return true; +} +export function isFollowersVisibleForMe(user: Misskey.entities.UserDetailed): boolean { + if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true; + + if (user.followersVisibility === 'private') return false; + if (user.followersVisibility === 'followers' && !user.isFollowing) return false; + + return true; +} diff --git a/packages/frontend/src/utility/key-event.ts b/packages/frontend/src/utility/key-event.ts new file mode 100644 index 0000000000..020a6c2174 --- /dev/null +++ b/packages/frontend/src/utility/key-event.ts @@ -0,0 +1,153 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * {@link KeyboardEvent.code} の値を表す文字列。不足分は適宜追加する + * @see https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values + */ +export type KeyCode = ( + | 'Backspace' + | 'Tab' + | 'Enter' + | 'Shift' + | 'Control' + | 'Alt' + | 'Pause' + | 'CapsLock' + | 'Escape' + | 'Space' + | 'PageUp' + | 'PageDown' + | 'End' + | 'Home' + | 'ArrowLeft' + | 'ArrowUp' + | 'ArrowRight' + | 'ArrowDown' + | 'Insert' + | 'Delete' + | 'Digit0' + | 'Digit1' + | 'Digit2' + | 'Digit3' + | 'Digit4' + | 'Digit5' + | 'Digit6' + | 'Digit7' + | 'Digit8' + | 'Digit9' + | 'KeyA' + | 'KeyB' + | 'KeyC' + | 'KeyD' + | 'KeyE' + | 'KeyF' + | 'KeyG' + | 'KeyH' + | 'KeyI' + | 'KeyJ' + | 'KeyK' + | 'KeyL' + | 'KeyM' + | 'KeyN' + | 'KeyO' + | 'KeyP' + | 'KeyQ' + | 'KeyR' + | 'KeyS' + | 'KeyT' + | 'KeyU' + | 'KeyV' + | 'KeyW' + | 'KeyX' + | 'KeyY' + | 'KeyZ' + | 'MetaLeft' + | 'MetaRight' + | 'ContextMenu' + | 'F1' + | 'F2' + | 'F3' + | 'F4' + | 'F5' + | 'F6' + | 'F7' + | 'F8' + | 'F9' + | 'F10' + | 'F11' + | 'F12' + | 'NumLock' + | 'ScrollLock' + | 'Semicolon' + | 'Equal' + | 'Comma' + | 'Minus' + | 'Period' + | 'Slash' + | 'Backquote' + | 'BracketLeft' + | 'Backslash' + | 'BracketRight' + | 'Quote' + | 'Meta' + | 'AltGraph' +); + +/** + * 修飾キーを表す文字列。不足分は適宜追加する。 + */ +export type KeyModifier = ( + | 'Shift' + | 'Control' + | 'Alt' + | 'Meta' +); + +/** + * 押下されたキー以外の状態を表す文字列。不足分は適宜追加する。 + */ +export type KeyState = ( + | 'composing' + | 'repeat' +); + +export type KeyEventHandler = { + modifiers?: KeyModifier[]; + states?: KeyState[]; + code: KeyCode | 'any'; + handler: (event: KeyboardEvent) => void; +}; + +export function handleKeyEvent(event: KeyboardEvent, handlers: KeyEventHandler[]) { + function checkModifier(ev: KeyboardEvent, modifiers? : KeyModifier[]) { + if (modifiers) { + return modifiers.every(modifier => ev.getModifierState(modifier)); + } + return true; + } + + function checkState(ev: KeyboardEvent, states?: KeyState[]) { + if (states) { + return states.every(state => ev.getModifierState(state)); + } + return true; + } + + let hit = false; + for (const handler of handlers.filter(it => it.code === event.code)) { + if (checkModifier(event, handler.modifiers) && checkState(event, handler.states)) { + handler.handler(event); + hit = true; + break; + } + } + + if (!hit) { + for (const handler of handlers.filter(it => it.code === 'any')) { + handler.handler(event); + } + } +} diff --git a/packages/frontend/src/utility/langmap.ts b/packages/frontend/src/utility/langmap.ts new file mode 100644 index 0000000000..b32de15963 --- /dev/null +++ b/packages/frontend/src/utility/langmap.ts @@ -0,0 +1,671 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// TODO: sharedに置いてバックエンドのと統合したい +export const langmap = { + 'ach': { + nativeName: 'Lwo', + }, + 'ady': { + nativeName: 'Адыгэбзэ', + }, + 'af': { + nativeName: 'Afrikaans', + }, + 'af-NA': { + nativeName: 'Afrikaans (Namibia)', + }, + 'af-ZA': { + nativeName: 'Afrikaans (South Africa)', + }, + 'ak': { + nativeName: 'Tɕɥi', + }, + 'ar': { + nativeName: 'العربية', + }, + 'ar-AR': { + nativeName: 'العربية', + }, + 'ar-MA': { + nativeName: 'العربية', + }, + 'ar-SA': { + nativeName: 'العربية (السعودية)', + }, + 'ay-BO': { + nativeName: 'Aymar aru', + }, + 'az': { + nativeName: 'Azərbaycan dili', + }, + 'az-AZ': { + nativeName: 'Azərbaycan dili', + }, + 'be-BY': { + nativeName: 'Беларуская', + }, + 'bg': { + nativeName: 'Български', + }, + 'bg-BG': { + nativeName: 'Български', + }, + 'bn': { + nativeName: 'বাংলা', + }, + 'bn-IN': { + nativeName: 'বাংলা (ভারত)', + }, + 'bn-BD': { + nativeName: 'বাংলা(বাংলাদেশ)', + }, + 'br': { + nativeName: 'Brezhoneg', + }, + 'bs-BA': { + nativeName: 'Bosanski', + }, + 'ca': { + nativeName: 'Català', + }, + 'ca-ES': { + nativeName: 'Català', + }, + 'cak': { + nativeName: 'Maya Kaqchikel', + }, + 'ck-US': { + nativeName: 'ᏣᎳᎩ (tsalagi)', + }, + 'cs': { + nativeName: 'Čeština', + }, + 'cs-CZ': { + nativeName: 'Čeština', + }, + 'cy': { + nativeName: 'Cymraeg', + }, + 'cy-GB': { + nativeName: 'Cymraeg', + }, + 'da': { + nativeName: 'Dansk', + }, + 'da-DK': { + nativeName: 'Dansk', + }, + 'de': { + nativeName: 'Deutsch', + }, + 'de-AT': { + nativeName: 'Deutsch (Österreich)', + }, + 'de-DE': { + nativeName: 'Deutsch (Deutschland)', + }, + 'de-CH': { + nativeName: 'Deutsch (Schweiz)', + }, + 'dsb': { + nativeName: 'Dolnoserbšćina', + }, + 'el': { + nativeName: 'Ελληνικά', + }, + 'el-GR': { + nativeName: 'Ελληνικά', + }, + 'en': { + nativeName: 'English', + }, + 'en-GB': { + nativeName: 'English (UK)', + }, + 'en-AU': { + nativeName: 'English (Australia)', + }, + 'en-CA': { + nativeName: 'English (Canada)', + }, + 'en-IE': { + nativeName: 'English (Ireland)', + }, + 'en-IN': { + nativeName: 'English (India)', + }, + 'en-PI': { + nativeName: 'English (Pirate)', + }, + 'en-SG': { + nativeName: 'English (Singapore)', + }, + 'en-UD': { + nativeName: 'English (Upside Down)', + }, + 'en-US': { + nativeName: 'English (US)', + }, + 'en-ZA': { + nativeName: 'English (South Africa)', + }, + 'en@pirate': { + nativeName: 'English (Pirate)', + }, + 'eo': { + nativeName: 'Esperanto', + }, + 'eo-EO': { + nativeName: 'Esperanto', + }, + 'es': { + nativeName: 'Español', + }, + 'es-AR': { + nativeName: 'Español (Argentine)', + }, + 'es-419': { + nativeName: 'Español (Latinoamérica)', + }, + 'es-CL': { + nativeName: 'Español (Chile)', + }, + 'es-CO': { + nativeName: 'Español (Colombia)', + }, + 'es-EC': { + nativeName: 'Español (Ecuador)', + }, + 'es-ES': { + nativeName: 'Español (España)', + }, + 'es-LA': { + nativeName: 'Español (Latinoamérica)', + }, + 'es-NI': { + nativeName: 'Español (Nicaragua)', + }, + 'es-MX': { + nativeName: 'Español (México)', + }, + 'es-US': { + nativeName: 'Español (Estados Unidos)', + }, + 'es-VE': { + nativeName: 'Español (Venezuela)', + }, + 'et': { + nativeName: 'eesti keel', + }, + 'et-EE': { + nativeName: 'Eesti (Estonia)', + }, + 'eu': { + nativeName: 'Euskara', + }, + 'eu-ES': { + nativeName: 'Euskara', + }, + 'fa': { + nativeName: 'فارسی', + }, + 'fa-IR': { + nativeName: 'فارسی', + }, + 'fb-LT': { + nativeName: 'Leet Speak', + }, + 'ff': { + nativeName: 'Fulah', + }, + 'fi': { + nativeName: 'Suomi', + }, + 'fi-FI': { + nativeName: 'Suomi', + }, + 'fo': { + nativeName: 'Føroyskt', + }, + 'fo-FO': { + nativeName: 'Føroyskt (Færeyjar)', + }, + 'fr': { + nativeName: 'Français', + }, + 'fr-CA': { + nativeName: 'Français (Canada)', + }, + 'fr-FR': { + nativeName: 'Français (France)', + }, + 'fr-BE': { + nativeName: 'Français (Belgique)', + }, + 'fr-CH': { + nativeName: 'Français (Suisse)', + }, + 'fy-NL': { + nativeName: 'Frysk', + }, + 'ga': { + nativeName: 'Gaeilge', + }, + 'ga-IE': { + nativeName: 'Gaeilge', + }, + 'gd': { + nativeName: 'Gàidhlig', + }, + 'gl': { + nativeName: 'Galego', + }, + 'gl-ES': { + nativeName: 'Galego', + }, + 'gn-PY': { + nativeName: 'Avañe\'ẽ', + }, + 'gu-IN': { + nativeName: 'ગુજરાતી', + }, + 'gv': { + nativeName: 'Gaelg', + }, + 'gx-GR': { + nativeName: 'Ἑλληνική ἀρχαία', + }, + 'he': { + nativeName: 'עברית', + }, + 'he-IL': { + nativeName: 'עברית', + }, + 'hi': { + nativeName: 'हिन्दी', + }, + 'hi-IN': { + nativeName: 'हिन्दी', + }, + 'hr': { + nativeName: 'Hrvatski', + }, + 'hr-HR': { + nativeName: 'Hrvatski', + }, + 'hsb': { + nativeName: 'Hornjoserbšćina', + }, + 'ht': { + nativeName: 'Kreyòl', + }, + 'hu': { + nativeName: 'Magyar', + }, + 'hu-HU': { + nativeName: 'Magyar', + }, + 'hy': { + nativeName: 'Հայերեն', + }, + 'hy-AM': { + nativeName: 'Հայերեն (Հայաստան)', + }, + 'id': { + nativeName: 'Bahasa Indonesia', + }, + 'id-ID': { + nativeName: 'Bahasa Indonesia', + }, + 'is': { + nativeName: 'Íslenska', + }, + 'is-IS': { + nativeName: 'Íslenska (Iceland)', + }, + 'it': { + nativeName: 'Italiano', + }, + 'it-IT': { + nativeName: 'Italiano', + }, + 'ja': { + nativeName: '日本語', + }, + 'ja-JP': { + nativeName: '日本語 (日本)', + }, + 'jv-ID': { + nativeName: 'Basa Jawa', + }, + 'ka-GE': { + nativeName: 'ქართული', + }, + 'kk-KZ': { + nativeName: 'Қазақша', + }, + 'km': { + nativeName: 'ភាសាខ្មែរ', + }, + 'kl': { + nativeName: 'kalaallisut', + }, + 'km-KH': { + nativeName: 'ភាសាខ្មែរ', + }, + 'kab': { + nativeName: 'Taqbaylit', + }, + 'kn': { + nativeName: 'ಕನ್ನಡ', + }, + 'kn-IN': { + nativeName: 'ಕನ್ನಡ (India)', + }, + 'ko': { + nativeName: '한국어', + }, + 'ko-KR': { + nativeName: '한국어 (한국)', + }, + 'ku-TR': { + nativeName: 'Kurdî', + }, + 'kw': { + nativeName: 'Kernewek', + }, + 'la': { + nativeName: 'Latin', + }, + 'la-VA': { + nativeName: 'Latin', + }, + 'lb': { + nativeName: 'Lëtzebuergesch', + }, + 'li-NL': { + nativeName: 'Lèmbörgs', + }, + 'lt': { + nativeName: 'Lietuvių', + }, + 'lt-LT': { + nativeName: 'Lietuvių', + }, + 'lv': { + nativeName: 'Latviešu', + }, + 'lv-LV': { + nativeName: 'Latviešu', + }, + 'mai': { + nativeName: 'मैथिली, মৈথিলী', + }, + 'mg-MG': { + nativeName: 'Malagasy', + }, + 'mk': { + nativeName: 'Македонски', + }, + 'mk-MK': { + nativeName: 'Македонски (Македонски)', + }, + 'ml': { + nativeName: 'മലയാളം', + }, + 'ml-IN': { + nativeName: 'മലയാളം', + }, + 'mn-MN': { + nativeName: 'Монгол', + }, + 'mr': { + nativeName: 'मराठी', + }, + 'mr-IN': { + nativeName: 'मराठी', + }, + 'ms': { + nativeName: 'Bahasa Melayu', + }, + 'ms-MY': { + nativeName: 'Bahasa Melayu', + }, + 'mt': { + nativeName: 'Malti', + }, + 'mt-MT': { + nativeName: 'Malti', + }, + 'my': { + nativeName: 'ဗမာစကာ', + }, + 'no': { + nativeName: 'Norsk', + }, + 'nb': { + nativeName: 'Norsk (bokmål)', + }, + 'nb-NO': { + nativeName: 'Norsk (bokmål)', + }, + 'ne': { + nativeName: 'नेपाली', + }, + 'ne-NP': { + nativeName: 'नेपाली', + }, + 'nl': { + nativeName: 'Nederlands', + }, + 'nl-BE': { + nativeName: 'Nederlands (België)', + }, + 'nl-NL': { + nativeName: 'Nederlands (Nederland)', + }, + 'nn-NO': { + nativeName: 'Norsk (nynorsk)', + }, + 'oc': { + nativeName: 'Occitan', + }, + 'or-IN': { + nativeName: 'ଓଡ଼ିଆ', + }, + 'pa': { + nativeName: 'ਪੰਜਾਬੀ', + }, + 'pa-IN': { + nativeName: 'ਪੰਜਾਬੀ (ਭਾਰਤ ਨੂੰ)', + }, + 'pl': { + nativeName: 'Polski', + }, + 'pl-PL': { + nativeName: 'Polski', + }, + 'ps-AF': { + nativeName: 'پښتو', + }, + 'pt': { + nativeName: 'Português', + }, + 'pt-BR': { + nativeName: 'Português (Brasil)', + }, + 'pt-PT': { + nativeName: 'Português (Portugal)', + }, + 'qu-PE': { + nativeName: 'Qhichwa', + }, + 'rm-CH': { + nativeName: 'Rumantsch', + }, + 'ro': { + nativeName: 'Română', + }, + 'ro-RO': { + nativeName: 'Română', + }, + 'ru': { + nativeName: 'Русский', + }, + 'ru-RU': { + nativeName: 'Русский', + }, + 'sa-IN': { + nativeName: 'संस्कृतम्', + }, + 'se-NO': { + nativeName: 'Davvisámegiella', + }, + 'sh': { + nativeName: 'српскохрватски', + }, + 'si-LK': { + nativeName: 'සිංහල', + }, + 'sk': { + nativeName: 'Slovenčina', + }, + 'sk-SK': { + nativeName: 'Slovenčina (Slovakia)', + }, + 'sl': { + nativeName: 'Slovenščina', + }, + 'sl-SI': { + nativeName: 'Slovenščina', + }, + 'so-SO': { + nativeName: 'Soomaaliga', + }, + 'sq': { + nativeName: 'Shqip', + }, + 'sq-AL': { + nativeName: 'Shqip', + }, + 'sr': { + nativeName: 'Српски', + }, + 'sr-RS': { + nativeName: 'Српски (Serbia)', + }, + 'su': { + nativeName: 'Basa Sunda', + }, + 'sv': { + nativeName: 'Svenska', + }, + 'sv-SE': { + nativeName: 'Svenska', + }, + 'sw': { + nativeName: 'Kiswahili', + }, + 'sw-KE': { + nativeName: 'Kiswahili', + }, + 'ta': { + nativeName: 'தமிழ்', + }, + 'ta-IN': { + nativeName: 'தமிழ்', + }, + 'te': { + nativeName: 'తెలుగు', + }, + 'te-IN': { + nativeName: 'తెలుగు', + }, + 'tg': { + nativeName: 'забо́ни тоҷикӣ́', + }, + 'tg-TJ': { + nativeName: 'тоҷикӣ', + }, + 'th': { + nativeName: 'ภาษาไทย', + }, + 'th-TH': { + nativeName: 'ภาษาไทย (ประเทศไทย)', + }, + 'fil': { + nativeName: 'Filipino', + }, + 'tlh': { + nativeName: 'tlhIngan-Hol', + }, + 'tr': { + nativeName: 'Türkçe', + }, + 'tr-TR': { + nativeName: 'Türkçe', + }, + 'tt-RU': { + nativeName: 'татарча', + }, + 'uk': { + nativeName: 'Українська', + }, + 'uk-UA': { + nativeName: 'Українська', + }, + 'ur': { + nativeName: 'اردو', + }, + 'ur-PK': { + nativeName: 'اردو', + }, + 'uz': { + nativeName: 'O\'zbek', + }, + 'uz-UZ': { + nativeName: 'O\'zbek', + }, + 'vi': { + nativeName: 'Tiếng Việt', + }, + 'vi-VN': { + nativeName: 'Tiếng Việt', + }, + 'xh-ZA': { + nativeName: 'isiXhosa', + }, + 'yi': { + nativeName: 'ייִדיש', + }, + 'yi-DE': { + nativeName: 'ייִדיש (German)', + }, + 'zh': { + nativeName: '中文', + }, + 'zh-Hans': { + nativeName: '中文简体', + }, + 'zh-Hant': { + nativeName: '中文繁體', + }, + 'zh-CN': { + nativeName: '中文(中国大陆)', + }, + 'zh-HK': { + nativeName: '中文(香港)', + }, + 'zh-SG': { + nativeName: '中文(新加坡)', + }, + 'zh-TW': { + nativeName: '中文(台灣)', + }, + 'zu-ZA': { + nativeName: 'isiZulu', + }, +}; diff --git a/packages/frontend/src/utility/login-id.ts b/packages/frontend/src/utility/login-id.ts new file mode 100644 index 0000000000..b52735caa0 --- /dev/null +++ b/packages/frontend/src/utility/login-id.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function getUrlWithLoginId(url: string, loginId: string) { + const u = new URL(url, origin); + u.searchParams.append('loginId', loginId); + return u.toString(); +} + +export function getUrlWithoutLoginId(url: string) { + const u = new URL(url); + u.searchParams.delete('loginId'); + return u.toString(); +} diff --git a/packages/frontend/src/utility/lookup.ts b/packages/frontend/src/utility/lookup.ts new file mode 100644 index 0000000000..d3a2d854a0 --- /dev/null +++ b/packages/frontend/src/utility/lookup.ts @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { i18n } from '@/i18n.js'; +import { Router } from '@/nirax.js'; +import { mainRouter } from '@/router/main.js'; + +export async function lookup(router?: Router) { + const _router = router ?? mainRouter; + + const { canceled, result: temp } = await os.inputText({ + title: i18n.ts.lookup, + }); + const query = temp ? temp.trim() : ''; + if (canceled || query.length <= 1) return; + + if (query.startsWith('@') && !query.includes(' ')) { + _router.push(`/${query}`); + return; + } + + if (query.startsWith('#')) { + _router.push(`/tags/${encodeURIComponent(query.substring(1))}`); + return; + } + + if (query.startsWith('https://')) { + const res = await apLookup(query); + + if (res.type === 'User') { + _router.push(`/@${res.object.username}@${res.object.host}`); + } else if (res.type === 'Note') { + _router.push(`/notes/${res.object.id}`); + } + + return; + } +} + +export async function apLookup(query: string) { + const promise = misskeyApi('ap/show', { + uri: query, + }); + + os.promiseDialog(promise, null, (err) => { + let title = i18n.ts.somethingHappened; + let text = err.message + '\n' + err.id; + + switch (err.id) { + case '974b799e-1a29-4889-b706-18d4dd93e266': + title = i18n.ts._remoteLookupErrors._federationNotAllowed.title; + text = i18n.ts._remoteLookupErrors._federationNotAllowed.description; + break; + case '1a5eab56-e47b-48c2-8d5e-217b897d70db': + title = i18n.ts._remoteLookupErrors._uriInvalid.title; + text = i18n.ts._remoteLookupErrors._uriInvalid.description; + break; + case '81b539cf-4f57-4b29-bc98-032c33c0792e': + title = i18n.ts._remoteLookupErrors._requestFailed.title; + text = i18n.ts._remoteLookupErrors._requestFailed.description; + break; + case '70193c39-54f3-4813-82f0-70a680f7495b': + title = i18n.ts._remoteLookupErrors._responseInvalid.title; + text = i18n.ts._remoteLookupErrors._responseInvalid.description; + break; + case 'dc94d745-1262-4e63-a17d-fecaa57efc82': + title = i18n.ts._remoteLookupErrors._noSuchObject.title; + text = i18n.ts._remoteLookupErrors._noSuchObject.description; + break; + } + + os.alert({ + type: 'error', + title, + text, + }); + }, i18n.ts.fetchingAsApObject); + + return await promise; +} diff --git a/packages/frontend/src/utility/media-has-audio.ts b/packages/frontend/src/utility/media-has-audio.ts new file mode 100644 index 0000000000..4bf3ee5d97 --- /dev/null +++ b/packages/frontend/src/utility/media-has-audio.ts @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export default async function hasAudio(media: HTMLMediaElement) { + const cloned = media.cloneNode() as HTMLMediaElement; + cloned.muted = (cloned as typeof cloned & Partial<HTMLVideoElement>).playsInline = true; + cloned.play(); + await new Promise((resolve) => cloned.addEventListener('playing', resolve)); + const result = !!(cloned as any).audioTracks?.length || (cloned as any).mozHasAudio || !!(cloned as any).webkitAudioDecodedByteCount; + cloned.remove(); + return result; +} diff --git a/packages/frontend/src/utility/media-proxy.ts b/packages/frontend/src/utility/media-proxy.ts new file mode 100644 index 0000000000..78eba35ead --- /dev/null +++ b/packages/frontend/src/utility/media-proxy.ts @@ -0,0 +1,34 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { MediaProxy } from '@@/js/media-proxy.js'; +import { url } from '@@/js/config.js'; +import { instance } from '@/instance.js'; + +let _mediaProxy: MediaProxy | null = null; + +export function getProxiedImageUrl(...args: Parameters<MediaProxy['getProxiedImageUrl']>): string { + if (_mediaProxy == null) { + _mediaProxy = new MediaProxy(instance, url); + } + + return _mediaProxy.getProxiedImageUrl(...args); +} + +export function getProxiedImageUrlNullable(...args: Parameters<MediaProxy['getProxiedImageUrlNullable']>): string | null { + if (_mediaProxy == null) { + _mediaProxy = new MediaProxy(instance, url); + } + + return _mediaProxy.getProxiedImageUrlNullable(...args); +} + +export function getStaticImageUrl(...args: Parameters<MediaProxy['getStaticImageUrl']>): string { + if (_mediaProxy == null) { + _mediaProxy = new MediaProxy(instance, url); + } + + return _mediaProxy.getStaticImageUrl(...args); +} diff --git a/packages/frontend/src/utility/merge.ts b/packages/frontend/src/utility/merge.ts new file mode 100644 index 0000000000..004b6d42a4 --- /dev/null +++ b/packages/frontend/src/utility/merge.ts @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { deepClone } from './clone.js'; +import type { Cloneable } from './clone.js'; + +export type DeepPartial<T> = { + [P in keyof T]?: T[P] extends Record<PropertyKey, unknown> ? DeepPartial<T[P]> : T[P]; +}; + +function isPureObject(value: unknown): value is Record<PropertyKey, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * valueにないキーをdefからもらう(再帰的)\ + * nullはそのまま、undefinedはdefの値 + **/ +export function deepMerge<X extends Record<PropertyKey, unknown>>(value: DeepPartial<X>, def: X): X { + if (isPureObject(value) && isPureObject(def)) { + const result = deepClone(value as Cloneable) as X; + for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) { + if (!Object.prototype.hasOwnProperty.call(value, k) || value[k] === undefined) { + result[k] = v; + } else if (isPureObject(v) && isPureObject(result[k])) { + const child = deepClone(result[k] as Cloneable) as DeepPartial<X[keyof X] & Record<PropertyKey, unknown>>; + result[k] = deepMerge<typeof v>(child, v); + } + } + return result; + } + throw new Error('deepMerge: value and def must be pure objects'); +} diff --git a/packages/frontend/src/utility/mfm-function-picker.ts b/packages/frontend/src/utility/mfm-function-picker.ts new file mode 100644 index 0000000000..a2f777f623 --- /dev/null +++ b/packages/frontend/src/utility/mfm-function-picker.ts @@ -0,0 +1,57 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { nextTick } from 'vue'; +import type { Ref } from 'vue'; +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; +import { MFM_TAGS } from '@@/js/const.js'; +import type { MenuItem } from '@/types/menu.js'; + +/** + * MFMの装飾のリストを表示する + */ +export function mfmFunctionPicker(src: HTMLElement | EventTarget | null, textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>) { + os.popupMenu([{ + text: i18n.ts.addMfmFunction, + type: 'label', + }, ...getFunctionList(textArea, textRef)], src); +} + +function getFunctionList(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>): MenuItem[] { + return MFM_TAGS.map(tag => ({ + text: tag, + icon: 'ti ti-icons', + action: () => add(textArea, textRef, tag), + })); +} + +function add(textArea: HTMLInputElement | HTMLTextAreaElement, textRef: Ref<string>, type: string) { + const caretStart: number = textArea.selectionStart as number; + const caretEnd: number = textArea.selectionEnd as number; + + MFM_TAGS.forEach(tag => { + if (type === tag) { + if (caretStart === caretEnd) { + // 単純にFunctionを追加 + const trimmedText = `${textRef.value.substring(0, caretStart)}$[${type} ]${textRef.value.substring(caretEnd)}`; + textRef.value = trimmedText; + } else { + // 選択範囲を囲むようにFunctionを追加 + const trimmedText = `${textRef.value.substring(0, caretStart)}$[${type} ${textRef.value.substring(caretStart, caretEnd)}]${textRef.value.substring(caretEnd)}`; + textRef.value = trimmedText; + } + } + }); + + const nextCaretStart: number = caretStart + 3 + type.length; + const nextCaretEnd: number = caretEnd + 3 + type.length; + + // キャレットを戻す + nextTick(() => { + textArea.focus(); + textArea.setSelectionRange(nextCaretStart, nextCaretEnd); + }); +} diff --git a/packages/frontend/src/utility/misskey-api.ts b/packages/frontend/src/utility/misskey-api.ts new file mode 100644 index 0000000000..dc07ad477b --- /dev/null +++ b/packages/frontend/src/utility/misskey-api.ts @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { ref } from 'vue'; +import { apiUrl } from '@@/js/config.js'; +import { $i } from '@/account.js'; +export const pendingApiRequestsCount = ref(0); + +export type Endpoint = keyof Misskey.Endpoints; + +export type Request<E extends Endpoint> = Misskey.Endpoints[E]['req']; + +export type AnyRequest<E extends Endpoint | (string & unknown)> = + (E extends Endpoint ? Request<E> : never) | object; + +export type Response<E extends Endpoint | (string & unknown), P extends AnyRequest<E>> = + E extends Endpoint + ? P extends Request<E> ? Misskey.api.SwitchCaseResponseType<E, P> : never + : object; + +// Implements Misskey.api.ApiClient.request +export function misskeyApi< + ResT = void, + E extends Endpoint | NonNullable<string> = Endpoint, + P extends AnyRequest<E> = E extends Endpoint ? Request<E> : never, + _ResT = ResT extends void ? Response<E, P> : ResT, +>( + endpoint: E, + data: P & { i?: string | null; } = {} as any, + token?: string | null | undefined, + signal?: AbortSignal, +): Promise<_ResT> { + if (endpoint.includes('://')) throw new Error('invalid endpoint'); + pendingApiRequestsCount.value++; + + const onFinally = () => { + pendingApiRequestsCount.value--; + }; + + const promise = new Promise<_ResT>((resolve, reject) => { + // Append a credential + if ($i) data.i = $i.token; + if (token !== undefined) data.i = token; + + // Send request + window.fetch(`${apiUrl}/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + credentials: 'omit', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + signal, + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(undefined as _ResT); // void -> undefined + } else { + reject(body.error); + } + }).catch(reject); + }); + + promise.then(onFinally, onFinally); + + return promise; +} + +// Implements Misskey.api.ApiClient.request +export function misskeyApiGet< + ResT = void, + E extends keyof Misskey.Endpoints = keyof Misskey.Endpoints, + P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req'], + _ResT = ResT extends void ? Misskey.api.SwitchCaseResponseType<E, P> : ResT, +>( + endpoint: E, + data: P = {} as any, +): Promise<_ResT> { + pendingApiRequestsCount.value++; + + const onFinally = () => { + pendingApiRequestsCount.value--; + }; + + const query = new URLSearchParams(data as any); + + const promise = new Promise<_ResT>((resolve, reject) => { + // Send request + window.fetch(`${apiUrl}/${endpoint}?${query}`, { + method: 'GET', + credentials: 'omit', + cache: 'default', + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(undefined as _ResT); // void -> undefined + } else { + reject(body.error); + } + }).catch(reject); + }); + + promise.then(onFinally, onFinally); + + return promise; +} diff --git a/packages/frontend/src/utility/navigator.ts b/packages/frontend/src/utility/navigator.ts new file mode 100644 index 0000000000..ffc0a457f4 --- /dev/null +++ b/packages/frontend/src/utility/navigator.ts @@ -0,0 +1,8 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function isSupportShare(): boolean { + return 'share' in navigator; +} diff --git a/packages/frontend/src/utility/page-metadata.ts b/packages/frontend/src/utility/page-metadata.ts new file mode 100644 index 0000000000..671751147c --- /dev/null +++ b/packages/frontend/src/utility/page-metadata.ts @@ -0,0 +1,71 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { inject, isRef, onActivated, onBeforeUnmount, provide, ref, toValue, watch } from 'vue'; +import type { MaybeRefOrGetter, Ref } from 'vue'; + +export type PageMetadata = { + title: string; + subtitle?: string; + icon?: string | null; + avatar?: Misskey.entities.User | null; + userName?: Misskey.entities.User | null; + needWideArea?: boolean; +}; + +type PageMetadataGetter = () => PageMetadata; +type PageMetadataReceiver = (getter: PageMetadataGetter) => void; + +const RECEIVER_KEY = Symbol('ReceiverKey'); +const setReceiver = (v: PageMetadataReceiver): void => { + provide<PageMetadataReceiver>(RECEIVER_KEY, v); +}; +const getReceiver = (): PageMetadataReceiver | undefined => { + return inject<PageMetadataReceiver>(RECEIVER_KEY); +}; + +const METADATA_KEY = Symbol('MetadataKey'); +const setMetadata = (v: Ref<PageMetadata | null>): void => { + provide<Ref<PageMetadata | null>>(METADATA_KEY, v); +}; +const getMetadata = (): Ref<PageMetadata | null> | undefined => { + return inject<Ref<PageMetadata | null>>(METADATA_KEY); +}; + +export const definePageMetadata = (maybeRefOrGetterMetadata: MaybeRefOrGetter<PageMetadata>): void => { + const metadataRef = ref(toValue(maybeRefOrGetterMetadata)); + const metadataGetter = () => metadataRef.value; + const receiver = getReceiver(); + + // setup handler + receiver?.(metadataGetter); + + // update handler + onBeforeUnmount(watch( + () => toValue(maybeRefOrGetterMetadata), + (metadata) => { + metadataRef.value = metadata; + receiver?.(metadataGetter); + }, + { deep: true }, + )); + onActivated(() => { + receiver?.(metadataGetter); + }); +}; + +export const provideMetadataReceiver = (receiver: PageMetadataReceiver): void => { + setReceiver(receiver); +}; + +export const provideReactiveMetadata = (metadataRef: Ref<PageMetadata | null>): void => { + setMetadata(metadataRef); +}; + +export const injectReactiveMetadata = (): Ref<PageMetadata | null> => { + const metadataRef = getMetadata(); + return isRef(metadataRef) ? metadataRef : ref(null); +}; diff --git a/packages/frontend/src/utility/physics.ts b/packages/frontend/src/utility/physics.ts new file mode 100644 index 0000000000..8a4e9319b3 --- /dev/null +++ b/packages/frontend/src/utility/physics.ts @@ -0,0 +1,157 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Matter from 'matter-js'; + +export function physics(container: HTMLElement) { + const containerWidth = container.offsetWidth; + const containerHeight = container.offsetHeight; + const containerCenterX = containerWidth / 2; + + // サイズ固定化(要らないかも?) + container.style.position = 'relative'; + container.style.boxSizing = 'border-box'; + container.style.width = `${containerWidth}px`; + container.style.height = `${containerHeight}px`; + + // create engine + const engine = Matter.Engine.create({ + constraintIterations: 4, + positionIterations: 8, + velocityIterations: 8, + }); + + const world = engine.world; + + // create renderer + const render = Matter.Render.create({ + engine: engine, + //element: document.getElementById('debug'), + options: { + width: containerWidth, + height: containerHeight, + background: 'transparent', // transparent to hide + wireframeBackground: 'transparent', // transparent to hide + }, + }); + + // Disable to hide debug + Matter.Render.run(render); + + // create runner + const runner = Matter.Runner.create(); + Matter.Runner.run(runner, engine); + + const groundThickness = 1024; + const ground = Matter.Bodies.rectangle(containerCenterX, containerHeight + (groundThickness / 2), containerWidth, groundThickness, { + isStatic: true, + restitution: 0.1, + friction: 2, + }); + + //const wallRight = Matter.Bodies.rectangle(window.innerWidth+50, window.innerHeight/2, 100, window.innerHeight, wallopts); + //const wallLeft = Matter.Bodies.rectangle(-50, window.innerHeight/2, 100, window.innerHeight, wallopts); + + Matter.World.add(world, [ + ground, + //wallRight, + //wallLeft, + ]); + + const objEls = Array.from(container.children) as HTMLElement[]; + const objs: Matter.Body[] = []; + for (const objEl of objEls) { + const left = objEl.dataset.physicsX ? parseInt(objEl.dataset.physicsX) : objEl.offsetLeft; + const top = objEl.dataset.physicsY ? parseInt(objEl.dataset.physicsY) : objEl.offsetTop; + + let obj: Matter.Body; + if (objEl.classList.contains('_physics_circle_')) { + obj = Matter.Bodies.circle( + left + (objEl.offsetWidth / 2), + top + (objEl.offsetHeight / 2), + Math.max(objEl.offsetWidth, objEl.offsetHeight) / 2, + { + restitution: 0.5, + }, + ); + } else { + const style = window.getComputedStyle(objEl); + obj = Matter.Bodies.rectangle( + left + (objEl.offsetWidth / 2), + top + (objEl.offsetHeight / 2), + objEl.offsetWidth, + objEl.offsetHeight, + { + chamfer: { radius: parseInt(style.borderRadius || '0', 10) }, + restitution: 0.5, + }, + ); + } + objEl.id = obj.id.toString(); + objs.push(obj); + } + + Matter.World.add(engine.world, objs); + + // Add mouse control + + const mouse = Matter.Mouse.create(container); + const mouseConstraint = Matter.MouseConstraint.create(engine, { + mouse: mouse, + constraint: { + stiffness: 0.1, + render: { + visible: false, + }, + }, + }); + + Matter.World.add(engine.world, mouseConstraint); + + // keep the mouse in sync with rendering + render.mouse = mouse; + + for (const objEl of objEls) { + objEl.style.position = 'absolute'; + objEl.style.top = '0'; + objEl.style.left = '0'; + objEl.style.margin = '0'; + } + + window.requestAnimationFrame(update); + + let stop = false; + + function update() { + for (const objEl of objEls) { + const obj = objs.find(obj => obj.id.toString() === objEl.id.toString()); + if (obj == null) continue; + + const x = (obj.position.x - objEl.offsetWidth / 2); + const y = (obj.position.y - objEl.offsetHeight / 2); + const angle = obj.angle; + objEl.style.transform = `translate(${x}px, ${y}px) rotate(${angle}rad)`; + } + + if (!stop) { + window.requestAnimationFrame(update); + } + } + + // 奈落に落ちたオブジェクトは消す + const intervalId = window.setInterval(() => { + for (const obj of objs) { + if (obj.position.y > (containerHeight + 1024)) Matter.World.remove(world, obj); + } + }, 1000 * 10); + + return { + stop: () => { + stop = true; + Matter.Runner.stop(runner); + window.clearInterval(intervalId); + }, + }; +} diff --git a/packages/frontend/src/utility/player-url-transform.ts b/packages/frontend/src/utility/player-url-transform.ts new file mode 100644 index 0000000000..39c6df6500 --- /dev/null +++ b/packages/frontend/src/utility/player-url-transform.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { hostname } from '@@/js/config.js'; + +export function transformPlayerUrl(url: string): string { + const urlObj = new URL(url); + if (!['https:', 'http:'].includes(urlObj.protocol)) throw new Error('Invalid protocol'); + + const urlParams = new URLSearchParams(urlObj.search); + + if (urlObj.hostname === 'player.twitch.tv') { + // TwitchはCSPの制約あり + // https://dev.twitch.tv/docs/embed/video-and-clips/ + urlParams.set('parent', hostname); + urlParams.set('allowfullscreen', ''); + urlParams.set('autoplay', 'true'); + } else { + urlParams.set('autoplay', '1'); + urlParams.set('auto_play', '1'); + } + urlObj.search = urlParams.toString(); + + return urlObj.toString(); +} diff --git a/packages/frontend/src/utility/please-login.ts b/packages/frontend/src/utility/please-login.ts new file mode 100644 index 0000000000..a8a330eb6d --- /dev/null +++ b/packages/frontend/src/utility/please-login.ts @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defineAsyncComponent } from 'vue'; +import { $i } from '@/account.js'; +import { instance } from '@/instance.js'; +import { i18n } from '@/i18n.js'; +import { popup } from '@/os.js'; + +export type OpenOnRemoteOptions = { + /** + * 外部のMisskey Webで特定のパスを開く + */ + type: 'web'; + + /** + * 内部パス(例: `/settings`) + */ + path: string; +} | { + /** + * 外部のMisskey Webで照会する + */ + type: 'lookup'; + + /** + * 照会したいエンティティのURL + * + * (例: `https://misskey.example.com/notes/abcdexxxxyz`) + */ + url: string; +} | { + /** + * 外部のMisskeyでノートする + */ + type: 'share'; + + /** + * `/share` ページに渡すクエリストリング + * + * @see https://go.misskey-hub.net/spec/share/ + */ + params: Record<string, string>; +}; + +export function pleaseLogin(opts: { + path?: string; + message?: string; + openOnRemote?: OpenOnRemoteOptions; +} = {}) { + if ($i) return; + + let _openOnRemote: OpenOnRemoteOptions | undefined = undefined; + + // 連合できる場合と、(連合ができなくても)共有する場合は外部連携オプションを設定 + if (opts.openOnRemote != null && (instance.federation !== 'none' || opts.openOnRemote.type === 'share')) { + _openOnRemote = opts.openOnRemote; + } + + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), { + autoSet: true, + message: opts.message ?? (_openOnRemote ? i18n.ts.signinOrContinueOnRemote : i18n.ts.signinRequired), + openOnRemote: _openOnRemote, + }, { + cancelled: () => { + if (opts.path) { + window.location.href = opts.path; + } + }, + closed: () => dispose(), + }); + + throw new Error('signin required'); +} diff --git a/packages/frontend/src/utility/popout.ts b/packages/frontend/src/utility/popout.ts new file mode 100644 index 0000000000..5b141222e8 --- /dev/null +++ b/packages/frontend/src/utility/popout.ts @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { appendQuery } from '@@/js/url.js'; +import * as config from '@@/js/config.js'; + +export function popout(path: string, w?: HTMLElement) { + let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path; + url = appendQuery(url, 'zen'); + if (w) { + const position = w.getBoundingClientRect(); + const width = parseInt(getComputedStyle(w, '').width, 10); + const height = parseInt(getComputedStyle(w, '').height, 10); + const x = window.screenX + position.left; + const y = window.screenY + position.top; + window.open(url, url, + `width=${width}, height=${height}, top=${y}, left=${x}`); + } else { + const width = 400; + const height = 500; + const x = window.top.outerHeight / 2 + window.top.screenY - (height / 2); + const y = window.top.outerWidth / 2 + window.top.screenX - (width / 2); + window.open(url, url, + `width=${width}, height=${height}, top=${x}, left=${y}`); + } +} diff --git a/packages/frontend/src/utility/popup-position.ts b/packages/frontend/src/utility/popup-position.ts new file mode 100644 index 0000000000..3dad41a8b3 --- /dev/null +++ b/packages/frontend/src/utility/popup-position.ts @@ -0,0 +1,161 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export function calcPopupPosition(el: HTMLElement, props: { + anchorElement?: HTMLElement | null; + innerMargin: number; + direction: 'top' | 'bottom' | 'left' | 'right'; + align: 'top' | 'bottom' | 'left' | 'right' | 'center'; + alignOffset?: number; + x?: number; + y?: number; +}): { top: number; left: number; transformOrigin: string; } { + const contentWidth = el.offsetWidth; + const contentHeight = el.offsetHeight; + + let rect: DOMRect; + + if (props.anchorElement) { + rect = props.anchorElement.getBoundingClientRect(); + } + + const calcPosWhenTop = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2); + top = (rect.top + window.scrollY - contentHeight) - props.innerMargin; + } else { + left = props.x; + top = (props.y - contentHeight) - props.innerMargin; + } + + left -= (el.offsetWidth / 2); + + if (left + contentWidth - window.scrollX > window.innerWidth) { + left = window.innerWidth - contentWidth + window.scrollX - 1; + } + + return [left, top]; + }; + + const calcPosWhenBottom = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = rect.left + window.scrollX + (props.anchorElement.offsetWidth / 2); + top = (rect.top + window.scrollY + props.anchorElement.offsetHeight) + props.innerMargin; + } else { + left = props.x; + top = (props.y) + props.innerMargin; + } + + left -= (el.offsetWidth / 2); + + if (left + contentWidth - window.scrollX > window.innerWidth) { + left = window.innerWidth - contentWidth + window.scrollX - 1; + } + + return [left, top]; + }; + + const calcPosWhenLeft = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = (rect.left + window.scrollX - contentWidth) - props.innerMargin; + top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2); + } else { + left = (props.x - contentWidth) - props.innerMargin; + top = props.y; + } + + top -= (el.offsetHeight / 2); + + if (top + contentHeight - window.scrollY > window.innerHeight) { + top = window.innerHeight - contentHeight + window.scrollY - 1; + } + + return [left, top]; + }; + + const calcPosWhenRight = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = (rect.left + props.anchorElement.offsetWidth + window.scrollX) + props.innerMargin; + + if (props.align === 'top') { + top = rect.top + window.scrollY; + if (props.alignOffset != null) top += props.alignOffset; + } else if (props.align === 'bottom') { + // TODO + } else { // center + top = rect.top + window.scrollY + (props.anchorElement.offsetHeight / 2); + top -= (el.offsetHeight / 2); + } + } else { + left = props.x + props.innerMargin; + top = props.y; + top -= (el.offsetHeight / 2); + } + + if (top + contentHeight - window.scrollY > window.innerHeight) { + top = window.innerHeight - contentHeight + window.scrollY - 1; + } + + return [left, top]; + }; + + const calc = (): { + left: number; + top: number; + transformOrigin: string; + } => { + switch (props.direction) { + case 'top': { + const [left, top] = calcPosWhenTop(); + + // ツールチップを上に向かって表示するスペースがなければ下に向かって出す + if (top - window.scrollY < 0) { + const [left, top] = calcPosWhenBottom(); + return { left, top, transformOrigin: 'center top' }; + } + + return { left, top, transformOrigin: 'center bottom' }; + } + + case 'bottom': { + const [left, top] = calcPosWhenBottom(); + // TODO: ツールチップを下に向かって表示するスペースがなければ上に向かって出す + return { left, top, transformOrigin: 'center top' }; + } + + case 'left': { + const [left, top] = calcPosWhenLeft(); + + // ツールチップを左に向かって表示するスペースがなければ右に向かって出す + if (left - window.scrollX < 0) { + const [left, top] = calcPosWhenRight(); + return { left, top, transformOrigin: 'left center' }; + } + + return { left, top, transformOrigin: 'right center' }; + } + + case 'right': { + const [left, top] = calcPosWhenRight(); + // TODO: ツールチップを右に向かって表示するスペースがなければ左に向かって出す + return { left, top, transformOrigin: 'left center' }; + } + } + }; + + return calc(); +} diff --git a/packages/frontend/src/utility/post-message.ts b/packages/frontend/src/utility/post-message.ts new file mode 100644 index 0000000000..11b6f52ddd --- /dev/null +++ b/packages/frontend/src/utility/post-message.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const postMessageEventTypes = [ + 'misskey:shareForm:shareCompleted', +] as const; + +export type PostMessageEventType = typeof postMessageEventTypes[number]; + +export type MiPostMessageEvent = { + type: PostMessageEventType; + payload?: any; +}; + +/** + * 親フレームにイベントを送信 + */ +export function postMessageToParentWindow(type: PostMessageEventType, payload?: any): void { + window.parent.postMessage({ + type, + payload, + }, '*'); +} diff --git a/packages/frontend/src/utility/reaction-picker.ts b/packages/frontend/src/utility/reaction-picker.ts new file mode 100644 index 0000000000..81f6c02dcf --- /dev/null +++ b/packages/frontend/src/utility/reaction-picker.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; +import { defineAsyncComponent, ref } from 'vue'; +import type { Ref } from 'vue'; +import { popup } from '@/os.js'; +import { store } from '@/store.js'; + +class ReactionPicker { + private src: Ref<HTMLElement | null> = ref(null); + private manualShowing = ref(false); + private targetNote: Ref<Misskey.entities.Note | null> = ref(null); + private onChosen?: (reaction: string) => void; + private onClosed?: () => void; + + constructor() { + // nop + } + + public async init() { + const reactionsRef = store.reactiveState.reactions; + await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { + src: this.src, + pinnedEmojis: reactionsRef, + asReactionPicker: true, + targetNote: this.targetNote, + manualShowing: this.manualShowing, + }, { + done: reaction => { + if (this.onChosen) this.onChosen(reaction); + }, + close: () => { + this.manualShowing.value = false; + }, + closed: () => { + this.src.value = null; + if (this.onClosed) this.onClosed(); + }, + }); + } + + public show(src: HTMLElement | null, targetNote: Misskey.entities.Note | null, onChosen?: ReactionPicker['onChosen'], onClosed?: ReactionPicker['onClosed']) { + this.src.value = src; + this.targetNote.value = targetNote; + this.manualShowing.value = true; + this.onChosen = onChosen; + this.onClosed = onClosed; + } +} + +export const reactionPicker = new ReactionPicker(); diff --git a/packages/frontend/src/utility/reload-ask.ts b/packages/frontend/src/utility/reload-ask.ts new file mode 100644 index 0000000000..057f57471a --- /dev/null +++ b/packages/frontend/src/utility/reload-ask.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; +import { unisonReload } from '@/utility/unison-reload.js'; + +let isReloadConfirming = false; + +export async function reloadAsk(opts: { + unison?: boolean; + reason?: string; +}) { + if (isReloadConfirming) { + return; + } + + isReloadConfirming = true; + + const { canceled } = await os.confirm(opts.reason == null ? { + type: 'info', + text: i18n.ts.reloadConfirm, + } : { + type: 'info', + title: i18n.ts.reloadConfirm, + text: opts.reason, + }).finally(() => { + isReloadConfirming = false; + }); + + if (canceled) return; + + if (opts.unison) { + unisonReload(); + } else { + location.reload(); + } +} diff --git a/packages/frontend/src/utility/search-emoji.ts b/packages/frontend/src/utility/search-emoji.ts new file mode 100644 index 0000000000..371f69b9a7 --- /dev/null +++ b/packages/frontend/src/utility/search-emoji.ts @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export type EmojiDef = { + emoji: string; + name: string; + url: string; + aliasOf?: string; +} | { + emoji: string; + name: string; + aliasOf?: string; + isCustomEmoji?: true; +}; +type EmojiScore = { emoji: EmojiDef, score: number }; + +export function searchEmoji(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] { + if (!query) { + return []; + } + + const matched = new Map<string, EmojiScore>(); + // 完全一致(エイリアスなし) + emojiDb.some(x => { + if (x.name === query && !x.aliasOf) { + matched.set(x.name, { emoji: x, score: query.length + 3 }); + } + return matched.size === max; + }); + + // 完全一致(エイリアス込み) + if (matched.size < max) { + emojiDb.some(x => { + if (x.name === query && !matched.has(x.aliasOf ?? x.name)) { + matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length + 2 }); + } + return matched.size === max; + }); + } + + // 前方一致(エイリアスなし) + if (matched.size < max) { + emojiDb.some(x => { + if (x.name.startsWith(query) && !x.aliasOf && !matched.has(x.name)) { + matched.set(x.name, { emoji: x, score: query.length + 1 }); + } + return matched.size === max; + }); + } + + // 前方一致(エイリアス込み) + if (matched.size < max) { + emojiDb.some(x => { + if (x.name.startsWith(query) && !matched.has(x.aliasOf ?? x.name)) { + matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length }); + } + return matched.size === max; + }); + } + + // 部分一致(エイリアス込み) + if (matched.size < max) { + emojiDb.some(x => { + if (x.name.includes(query) && !matched.has(x.aliasOf ?? x.name)) { + matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length - 1 }); + } + return matched.size === max; + }); + } + + // 簡易あいまい検索(3文字以上) + if (matched.size < max && query.length > 3) { + const queryChars = [...query]; + const hitEmojis = new Map<string, EmojiScore>(); + + for (const x of emojiDb) { + // 文字列の位置を進めながら、クエリの文字を順番に探す + + let pos = 0; + let hit = 0; + for (const c of queryChars) { + pos = x.name.indexOf(c, pos); + if (pos <= -1) break; + hit++; + } + + // 半分以上の文字が含まれていればヒットとする + if (hit > Math.ceil(queryChars.length / 2) && hit - 2 > (matched.get(x.aliasOf ?? x.name)?.score ?? 0)) { + hitEmojis.set(x.aliasOf ?? x.name, { emoji: x, score: hit - 2 }); + } + } + + // ヒットしたものを全部追加すると雑多になるので、先頭の6件程度だけにしておく(6件=オートコンプリートのポップアップのサイズ分) + [...hitEmojis.values()] + .sort((x, y) => y.score - x.score) + .slice(0, 6) + .forEach(it => matched.set(it.emoji.name, it)); + } + + return [...matched.values()] + .sort((x, y) => y.score - x.score) + .slice(0, max) + .map(it => it.emoji); +} diff --git a/packages/frontend/src/utility/select-file.ts b/packages/frontend/src/utility/select-file.ts new file mode 100644 index 0000000000..1bee4986f6 --- /dev/null +++ b/packages/frontend/src/utility/select-file.ts @@ -0,0 +1,130 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import * as os from '@/os.js'; +import { misskeyApi } from '@/utility/misskey-api.js'; +import { useStream } from '@/stream.js'; +import { i18n } from '@/i18n.js'; +import { uploadFile } from '@/utility/upload.js'; +import { prefer } from '@/preferences.js'; + +export function chooseFileFromPc( + multiple: boolean, + options?: { + uploadFolder?: string | null; + keepOriginal?: boolean; + nameConverter?: (file: File) => string | undefined; + }, +): Promise<Misskey.entities.DriveFile[]> { + const uploadFolder = options?.uploadFolder ?? prefer.s.uploadFolder; + const keepOriginal = options?.keepOriginal ?? prefer.s.keepOriginalUploading; + const nameConverter = options?.nameConverter ?? (() => undefined); + + return new Promise((res, rej) => { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = multiple; + input.onchange = () => { + if (!input.files) return res([]); + const promises = Array.from( + input.files, + file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal), + ); + + Promise.all(promises).then(driveFiles => { + res(driveFiles); + }).catch(err => { + // アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない + }); + + // 一応廃棄 + (window as any).__misskey_input_ref__ = null; + }; + + // https://qiita.com/fukasawah/items/b9dc732d95d99551013d + // iOS Safari で正常に動かす為のおまじない + (window as any).__misskey_input_ref__ = input; + + input.click(); + }); +} + +export function chooseFileFromDrive(multiple: boolean): Promise<Misskey.entities.DriveFile[]> { + return new Promise((res, rej) => { + os.selectDriveFile(multiple).then(files => { + res(files); + }); + }); +} + +export function chooseFileFromUrl(): Promise<Misskey.entities.DriveFile> { + return new Promise((res, rej) => { + os.inputText({ + title: i18n.ts.uploadFromUrl, + type: 'url', + placeholder: i18n.ts.uploadFromUrlDescription, + }).then(({ canceled, result: url }) => { + if (canceled) return; + + const marker = Math.random().toString(); // TODO: UUIDとか使う + + const connection = useStream().useChannel('main'); + connection.on('urlUploadFinished', urlResponse => { + if (urlResponse.marker === marker) { + res(urlResponse.file); + connection.dispose(); + } + }); + + misskeyApi('drive/files/upload-from-url', { + url: url, + folderId: prefer.s.uploadFolder, + marker, + }); + + os.alert({ + title: i18n.ts.uploadFromUrlRequested, + text: i18n.ts.uploadFromUrlMayTakeTime, + }); + }); + }); +} + +function select(src: HTMLElement | EventTarget | null, label: string | null, multiple: boolean): Promise<Misskey.entities.DriveFile[]> { + return new Promise((res, rej) => { + const keepOriginal = ref(prefer.s.keepOriginalUploading); + + os.popupMenu([label ? { + text: label, + type: 'label', + } : undefined, { + type: 'switch', + text: i18n.ts.keepOriginalUploading, + ref: keepOriginal, + }, { + text: i18n.ts.upload, + icon: 'ti ti-upload', + action: () => chooseFileFromPc(multiple, { keepOriginal: keepOriginal.value }).then(files => res(files)), + }, { + text: i18n.ts.fromDrive, + icon: 'ti ti-cloud', + action: () => chooseFileFromDrive(multiple).then(files => res(files)), + }, { + text: i18n.ts.fromUrl, + icon: 'ti ti-link', + action: () => chooseFileFromUrl().then(file => res([file])), + }], src); + }); +} + +export function selectFile(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile> { + return select(src, label, false).then(files => files[0]); +} + +export function selectFiles(src: HTMLElement | EventTarget | null, label: string | null = null): Promise<Misskey.entities.DriveFile[]> { + return select(src, label, true); +} diff --git a/packages/frontend/src/utility/show-moved-dialog.ts b/packages/frontend/src/utility/show-moved-dialog.ts new file mode 100644 index 0000000000..35b3ef79d8 --- /dev/null +++ b/packages/frontend/src/utility/show-moved-dialog.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as os from '@/os.js'; +import { $i } from '@/account.js'; +import { i18n } from '@/i18n.js'; + +export function showMovedDialog() { + if (!$i) return; + if (!$i.movedTo) return; + + os.alert({ + type: 'error', + title: i18n.ts.accountMovedShort, + text: i18n.ts.operationForbidden, + }); + + throw new Error('account moved'); +} diff --git a/packages/frontend/src/utility/show-suspended-dialog.ts b/packages/frontend/src/utility/show-suspended-dialog.ts new file mode 100644 index 0000000000..8b89dbb936 --- /dev/null +++ b/packages/frontend/src/utility/show-suspended-dialog.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as os from '@/os.js'; +import { i18n } from '@/i18n.js'; + +export function showSuspendedDialog() { + return os.alert({ + type: 'error', + title: i18n.ts.yourAccountSuspendedTitle, + text: i18n.ts.yourAccountSuspendedDescription, + }); +} diff --git a/packages/frontend/src/utility/shuffle.ts b/packages/frontend/src/utility/shuffle.ts new file mode 100644 index 0000000000..1f6ef1928c --- /dev/null +++ b/packages/frontend/src/utility/shuffle.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * 配列をシャッフル (破壊的) + */ +export function shuffle<T extends unknown[]>(array: T): T { + let currentIndex = array.length; + let randomIndex: number; + + // While there remain elements to shuffle. + while (currentIndex !== 0) { + // Pick a remaining element. + randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex--; + + // And swap it with the current element. + [array[currentIndex], array[randomIndex]] = [ + array[randomIndex], array[currentIndex]]; + } + + return array; +} diff --git a/packages/frontend/src/utility/snowfall-effect.ts b/packages/frontend/src/utility/snowfall-effect.ts new file mode 100644 index 0000000000..d88bdb6660 --- /dev/null +++ b/packages/frontend/src/utility/snowfall-effect.ts @@ -0,0 +1,490 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class SnowfallEffect { + private VERTEX_SOURCE = `#version 300 es + in vec4 a_position; + in vec4 a_color; + in vec3 a_rotation; + in vec3 a_speed; + in float a_size; + out vec4 v_color; + out float v_rotation; + uniform float u_time; + uniform mat4 u_projection; + uniform vec3 u_worldSize; + uniform float u_gravity; + uniform float u_wind; + uniform float u_spin_factor; + uniform float u_turbulence; + + void main() { + v_color = a_color; + v_rotation = a_rotation.x + (u_time * u_spin_factor) * a_rotation.y; + + vec3 pos = a_position.xyz; + + pos.x = mod(pos.x + u_time + u_wind * a_speed.x, u_worldSize.x * 2.0) - u_worldSize.x; + pos.y = mod(pos.y - u_time * a_speed.y * u_gravity, u_worldSize.y * 2.0) - u_worldSize.y; + + pos.x += sin(u_time * a_speed.z * u_turbulence) * a_rotation.z; + pos.z += cos(u_time * a_speed.z * u_turbulence) * a_rotation.z; + + gl_Position = u_projection * vec4(pos.xyz, a_position.w); + gl_PointSize = (a_size / gl_Position.w) * 100.0; + } + `; + + private FRAGMENT_SOURCE = `#version 300 es + precision highp float; + + in vec4 v_color; + in float v_rotation; + uniform sampler2D u_texture; + out vec4 out_color; + + void main() { + vec2 rotated = vec2( + cos(v_rotation) * (gl_PointCoord.x - 0.5) + sin(v_rotation) * (gl_PointCoord.y - 0.5) + 0.5, + cos(v_rotation) * (gl_PointCoord.y - 0.5) - sin(v_rotation) * (gl_PointCoord.x - 0.5) + 0.5 + ); + + vec4 snowflake = texture(u_texture, rotated); + + out_color = vec4(snowflake.rgb * v_color.xyz, snowflake.a * v_color.a); + } + `; + + private gl: WebGLRenderingContext; + private program: WebGLProgram; + private canvas: HTMLCanvasElement; + private buffers: Record<string, { + size: number; + value: number[] | Float32Array; + location: number; + ref: WebGLBuffer; + }>; + private uniforms: Record<string, { + type: string; + value: number[] | Float32Array; + location: WebGLUniformLocation; + }>; + private texture: WebGLTexture; + private camera: { + fov: number; + near: number; + far: number; + aspect: number; + z: number; + }; + private wind: { + current: number; + force: number; + target: number; + min: number; + max: number; + easing: number; + }; + private time: { + start: number; + previous: number; + } = { + start: 0, + previous: 0, + }; + private raf = 0; + + private density: number = 1 / 90; + private depth = 100; + private count = 1000; + private gravity = 100; + private speed: number = 1 / 10000; + private color: number[] = [1, 1, 1]; + private opacity = 1; + private size = 4; + private snowflake = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAAErRJREFUeAHdmgnYlmPax5MShaxRKRElPmXJXpaSsRxDU0bTZ+kt65RloiRDltEMQsxYKmS+zzYjxCCamCzV2LchResMIxFRQ1G93+93Pdf5dL9v7zuf4/hm0fc/jt9znddy3/e1nNd53c/7vHXq/AtVWVnZA/bzkaQjoWG298DeMdvrmP6/EIOqC4fBsbAx7Arz4TaYBPXgWVDnO2jSBrB2T0IMIA9mCmmoE8aonPkR6WPZHlp9xSlfeyeBzq9bHBD5feEdUGfDXBgBqnde+a2wvw/dYdNctvZNAp1PnTaFttA6JgP7eVgBM0CNzgO9HNvy0AcYDda6SaDTdXOnz8X+IkZDugAGQmOYA+ob6Ah/MIOMDRPhJjgJ6uV7pXtWt81/50SnY/Wvwn4ZDHAvwJ9ATYcxyaqsnEnqZCyCPaE80BgYZXG/5A3VyyP/b08LHa11z9KmFUwA5eqruRBHYX1s8WSI1Xcbme8Mt8PWUCU+kF8XbFN+dtH+p06OD4IU8EjD/VOZ5bnezq0XHcHuC2oV7BDlkVIWq56uIX8UjAO31GRIMYW0Vo/xXtSXJyTuXVO6xk1qalRTmQ9AfqzEvog2XYpllnsd6Qr4unCPT7NtByu0uU7vuAaOoy1JuvfXpJdTvSX0gI1gCXwGZdFmEFxoQb7Wid8s7lNu+I8wuHGsTqz2zpQ9DAa5R6HC55A2gvCMXthvwi25bjx26H0M9/9f4Rnok9s0zulFlC2HzzP9cnld8nH/p7DVrbmuIfYs6JLz9U3/z+KGadDeCDsmwre7GyEifn/su8HVSsL2HeBn8CK8AW+B7u9R5yrPgyOjvSn5DWAaXAG2UU7CE9Ayt4k4sR1lX4LaLdd9gn2ftsL+Vtuh1Dp/elH1C8lvCdUj8kDK3gbP8XdhCnSC86rcsNSR9pQvhc/gVlB9bUfqoFNAy/mLrUROrpMwCtpBxBbTtLqkF4K6IF9rf57I9pnYekx5AS0P1VhopXso9pR5buC7+kewU86nFcB+BT4EXdIvNO73sRBubGTXLZtTtgp+DEb++bACdqBuJOlAaMMzLVM3whegNznQDtCb+pW5b8YY76euB5+7pxm0IbzCfS8m3Zf2q4T8/+4JNArXGoptpxz8LqDmQJq0Qnostt/sfIn5GygD4/Zeq7B7wljQO2yjB/QGj0Pjxz4wGdqXrkjXtCT/ISyDa6EPpHrSraFjvnecFpMoMx40Br3xSlD262rYObevddHTs2kYwWUG9uP5It/f1eU5Xw9btwoXPALbwYXcg+unG/KB3Rq8n9ddAOpn4Kr8BAaBcltcDo9D7Ouavig1o34x7F94xqPk74eLQH0MH8HvwS3SLPe9iheEG6f70KiuLpZv6sxG/Va5bFJOabaO7ucAvGEbeAH+AN1hV7iDOidQFz4A2oJb6D1YDhXZHkTqpL8EbqHDYRtwW20AsdIb8syl5N2e6dTAPB2mWYa+hE4Qk7I59iMwFZ70GlJlfyuTVfygs7Hyw7HbwI0w3Tak14BqEtdg7wVdIx8pZbtBUbrjZeA3vUPBANkU+sEehev8O4Db6QpwYm+D8II0KPKHwUFeQ3oLDIMN4WgID1yOPQ+MAXMhNAtju3ztmtuAypiAw7EXwo/Am+0NfUG5mknYc6GfGVIjsoFNuyuoh8COuDcd2LmwA9jWE8bB3Q7N4XrwWAz5XOXR+Tx4n6FgdHeB6sF/w2QwhlSXdXvl/jixx4NH8GW5LDzb7GrR4ES4F5QddB99CieAwStOAPegdUZ2B71F3AXbQSn3vJ1bYaYWrayh3NUPTcbYFExVW3CfXwlvgfoavMbnDAY9dxGo6dCt0LeaB54H4UydDEPA2R4PDlrFLB9XuNmTlO+Xr7X9ZNBr9J4+EN8AMcv6ButpMND9FM6EnTOHkLrSnvtzwbbq3vwMB2ow/qWFSC8ZC++ZQaldbquH2afQWbl8TdcvVtC6LtipifAuOKt6gA9Tzqgzb5R2gP1hX3DVtZVHVvdklY5DA5beIkVPuZn8LOgAnWEfeAaUkxCan/voBNkfF+U5cFu5z5XlxZU20OmZtgm1K45VO4naNCukrcBZVk/CD+E/YBjoYjXJY8Zg9DxsDrbbBHTRotxOrug4eBs+hHgWZtKzfHrdXHBi9gDvqzxFHNA5KVfyBCf0ExgB7nkXStLLEKkniNf0AzUs5+ublkVFKiC9FBZAvGxshT0NnN3zoSUYSJQPcjAvm0HmjcIPemNS96F6E36drFLwugx7EEzNZV/l9IjoEPkW4B7eFtYH9QKcBcfA/aCWgpPQOT+zMbb9fS3nDbYR2MdgV0S5aVlUhLs0w45IHi7sqnnGJ2E7CXqHWgZXgJ1y8KqpDUmfSLmSV5yB/XrpDqVP8ofmehNdOv7I0ShfP4yyJdl2a4SchI1gCXgkHgljYfvc1i3cs/SU1A9jQRpfri/b0Sal1RrtSj4ULyHprY5C6+6E1+EBULq0E+DK7A96iwqX0z4td8B3dCdob5gD3UB3j9fUcNuDKFOvgc+bZAZFf4Zgu/q/AGPMgfm+5ShPWay+k6I31BwAvVDRYL2cuqfUVTkfnTqvVFx5ai7/MXn3tp1UrtRkDWRsaAMjzaD08uJ1irz7+8ps/6ZYj90V3FKrQBkvmubULbN7vs7tZRyJV9w0ePLbQ4PcJspqXnkbhbgoGk/AVptZRxpB0hU7Mpc1x34cdgKPm1dzeTts9XPwlFAO5Au4BDbO7ZycO7J9A/Zh2b4A2+ucALefWpTrflDKVq4kHQBOoi9PO1qvsDeGd6AxXAJbQ5VxlFrW8EnDcJlTsOPcjElxL7WNy7AduC4f2+A/rSN/Hyg7YMBTxgqPUT3F2HAqtIb58GvQW86GqyG+ff4UWz0FBuH4UhaTal1vmAGfg98dfP4d4HPGwmwYAg+D2/J7uU0ap/YaolHZVbBj5d1DaSK8ADsmqiH2JIhgNRhbPZrbhSdZ5heVJGw7477VfYuaagMK2sM8iMloga1HXAt/AeWELgQnR/0Z7k3W6pe3xTn/JamTFPGnPMZSj6p90rA8YOziwHcnH/EgTovJlJ0LPSHkyrTKmZNJ+8KrYKBsCQeB0pWdBFNleieMgzjL44jejTK1CPSY0CiMdyOT09g6ni5O3Ceg51U4VNLaPSA3SDNEwwiKFdgHgANNrpjb7UVejYTYCuZ92DR42HYh8gfDJfAMqBi4dqxk+RrKGkD0YXNsA6AT5qCUXhBe5CR0gPCC4dhqKFwI1m1qX0hr94CotDE4aAd3PCyBX4Jyn+sNL5tBDsRAp3S7b5KVYwa2A0nHaO5AXBeDtnlMxizsW+HomLh8zX9R5sTeBSEn/cqc2Tvak9eDXCyP2PgbYWzn2gefHxT7+0Qu/h18DO7XmPWYcYqSXuHz2myb6G7RNs7meLgeMxXugbiPA3clQx0xtgNPGN819L7+oCzvm6zSx+EkI+Du3Pe0LbOd/jqc7dhG9Wib+mJ5jaJBuL8e4B5aAMpAomKlb8d+KZWUVnw+dgzKSdDtvKaLDyJ1ReZB7O0J2EV5Xwd8OsTJExNpu7Q1SJ8zgy7K93UCX4P4mr4udoyhPGDKygOP+tomIFarMw2d+cfgF2DnDVAGoBvzw33YTHgPDoXQ7Fx/Wy6YkdMrcrmrehO4Pz3WvP90cIVPgonwITg4973yu0XTZK0+ZQaQd+K816twVAwKO71ZRj9zeg7lcVzXHghpVN4n2G3BAHQ1NILx4MBjoppgLwL3Ww8IHZsf6vGk3O8fwx9heK7rhD0o2zdg75JtT6GzQQ8KzcZwElSr3M5J85ktYCzEG+Gx2NNzm/Cm5pSp+K2gfLrZbg3RcB2IQcZN1qPM3+l06SjbAltX/TiXe1wtg7+AdR+AcgIs7xUPw94XxuTrnOD4E1bEoe9Rptw+DWGOGeQi7JOs1SfKKfk+epcakPNxbI8uFVdem8vT6aJdq7jASYjOFPdQDP4Q6t+Em8HVutmbkbYH9Tv4LcQW+H6ujy9Wrtxc6A7vQnznb5TbHUPZ0mw7CeoaOBAegmfBIKw8WZzs34M/oNiPGPzB2KHdrVMUlD29VFLLpw2jMWmnaIbdDNxXur+dWgVumTMglI4zMgbUEV5LmjqW7XnRkDS9qhbu/xZlZ8LWuc3UfM22Of80aVcYDJ/lstdIWxXu0TGXm/TO19vveHWuOglUxOo6iMfyBe7JOEp01ech9puuuBCMA8pVcUUNUB5lqgMYwJyE1oXOGTh9v1gO6kmogKEwHtREMHYofz5zAl3lJ2AWqJfgfohJiKB8HWWfg54YA9Zr1fn5Xmm80SdvHhNwVmq2umF8vWxA+WRwwE9BPNhOulrq0nxz97j6Go6DF8HYcBfYyer6MwWuoINeDG6roq4iE97QCtsJuxWc2JrkCeKEbgX7waOgnLiavxdQEWfohtgRwCrygIoxoQv1K0FNgR7gAKPTB+dr5lAWMliqmbAb7AzbgCs42vYK21NmOiwHJ9atpdxqDlhdA75QdYJT4XUYDfbBiVRe5ySoZTAbBpeekp6T4lo5uFnBz0fpJ6P8E9SJufEdXHipdRA/mw2hzmvfhrfgfjCKPwJnwn2g3igldb4hNaD5a6/fz7eHVuAb2wPwPs+4DB7E/hTagd64BbgoC6Ab9IAfgn+OX0p/ppAaGxZjnw6+Ep8DK8Cj0IDrmHw3GaeN9EZ/AlxFfk1RuVGUYu8K00D9Fa6EvrAUVKzO29gXg9vC1VW3g540w0xBcU2hKJnz+FxYvTCXWaduK/StuTZlLcD6JjnfEvsb6A56m32z78q4FMGw1gA4lEa60WmwMeiSnsljIBSDmEOBE3RdfvggbMuMIbNhItgJtbyUpE9ddjA0Bid1sderXDaQ1OdPAO9zH6hDcpuG2Ml7SQfArHRx6Xpf3JTluySrsrIP6Seg9/iMqsEvF6YZoXIDeAZCRmpneAHEnnLQnaEuXATX53schR3n/e7YyuvOT1bpnyV107Io3xZ6QWs4EirAyXkEqqvK3xa9CQ0c5C5xQ+zN8kWjcr2xZxTsBHfmsipbP671ZmW3wHYA58DdEPobhtwVF2HfBE9H3pT8xjkdja3iiDK4PQBO8Dx4B9wiH8JKeANcKTUW9IITwKNMeYrcArfDhVDsb1pVyty26le5D97/zWzrzVUGXyVjI0WjHUgq4CjoAuGiRuuJkN7mSJX7cn+uaZNyfBBgDHZqXvqsU2cZ6aPwChgE/ap8M9wLbSH+0DKOaw18z8N12GPAyf4BfADbwBmwCbxAHY9NvxQXx2GgVLZXPvurZDE0rqk5+NmAm8U2aIbdH9yDalgpSS80ltlB29fPqW9c8XLUHnsIuGquqt8gN7edwtazrOsAn4MysLryX8BD4Ap3y+0dZROIwPsl9h/hHjgit4lXdrdvHN8dc91wyk7JdvIS7VpF46Jb2ZGz4WJIRyBpBKQW3oR8lZuSvwQMhKtAfQUpYuf27cgbNx6EEeDAzgMHPwYMYi2gEcSfxC7B9qicDMoo/1vQI8p9IG88WAY/yeVpYrJdHpf5vytu4Ky7X46xIamrvjDb52OrG3K+HrZt4xq9wYEZPGPVfp7bhsdE2os2ylV6J1n5mbYPUX4S7AkGX+OAk2t6mm1Iw3PtQ+O4LuooK26RYvW3s7nBLZDiAGlbUHYiRV/S5AWk28DTEFqB4eo+B+n1M55Ivhu4kspj92uYCm6Px0Gv61lor0fcDQNBrQQnOr71lVeYsm894L/bkBuFe/u93eBngJtJMlwTDIDKyfDt6n3se8Dt8jHoNU0o70waq34obZ8lPx4coG+LbifrP6Pt0aQvwn65LFzcAHY8ZUtgAnwExp2WoMpeQLvaA12p7bf/pLPFmS3a/ajr750cfE43wX4YYmU9wi7IddHBCsrc69vm8uuwQydYVhQVvmsUn7s+ebfD0GhXrI+yf2jqA4oPKdo+iHxMwHbYRmgjta4cUTqCWXkg0UHatIR4SxxWKK9PeXhgKiZfxWOthzXuGff4p6b54bH3Y3W3pNxJcK8ebgdI44iys0G0N/8qKGOAGg9Ni50n3yjy2GkxSKtMRtT/21I7Fg/H9lRIX6qK5YX6zSjvDL4BGiBfBnUNmFdzwfKX4Ct40OtJv1sDj0Hlzrk6xbM3tob7uCf4amyk96VHvQg7gltGzQG9wpcwX6BCesfJ3/kJiMmgs+Gm4errUeZqF+Up4IoOzoWLcmqETyLve/2BsKkFpGUvK7VYCz6j06RbQx+ogHhN3Qdb3QF+a/wVKF94OhSHR77sWcXytcKm82usHGW9QE2B3skq/QB7APaqnJ9NuvaufnF1GIhxYH3LSAeA+hM0hMfgNzATdHvjgDHDv+qkP8gW77XW2gwmYsJe2F3zZDgxI7NteTo+/1WD/B9Au3Zjh2RyrgAAAABJRU5ErkJggg=='; + private mode = 'snow'; + + private INITIAL_BUFFERS = () => ({ + position: { size: 3, value: [] }, + color: { size: 4, value: [] }, + size: { size: 1, value: [] }, + rotation: { size: 3, value: [] }, + speed: { size: 3, value: [] }, + }); + + private INITIAL_UNIFORMS = () => ({ + time: { type: 'float', value: 0 }, + worldSize: { type: 'vec3', value: [0, 0, 0] }, + gravity: { type: 'float', value: this.gravity }, + wind: { type: 'float', value: 0 }, + spin_factor: { type: 'float', value: this.mode === 'sakura' ? 8 : 1 }, + turbulence: { type: 'float', value: this.mode === 'sakura' ? 2 : 1 }, + projection: { + type: 'mat4', + value: [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + }, + }); + + private UNIFORM_SETTERS = { + int: 'uniform1i', + float: 'uniform1f', + vec2: 'uniform2fv', + vec3: 'uniform3fv', + vec4: 'uniform4fv', + mat2: 'uniformMatrix2fv', + mat3: 'uniformMatrix3fv', + mat4: 'uniformMatrix4fv', + }; + + private CAMERA = { + fov: 60, + near: 5, + far: 10000, + aspect: 1, + z: 100, + }; + + private WIND = { + current: 0, + force: 0.01, + target: 0.01, + min: 0, + max: 0.125, + easing: 0.0005, + }; + /** + * @throws {Error} - Thrown when it fails to get WebGL context for the canvas + */ + constructor(options: { + sakura?: boolean; + }) { + if (options.sakura) { + this.mode = 'sakura'; + this.snowflake = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAFEmlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNS41LjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICAgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgeG1wOkNyZWF0ZURhdGU9IjIwMjQtMDItMDFUMTQ6Mzk6NTYrMDkwMCIKICAgeG1wOk1vZGlmeURhdGU9IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiCiAgIHhtcDpNZXRhZGF0YURhdGU9IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiCiAgIHBob3Rvc2hvcDpEYXRlQ3JlYXRlZD0iMjAyNC0wMi0wMVQxNDozOTo1NiswOTAwIgogICBwaG90b3Nob3A6Q29sb3JNb2RlPSIzIgogICBwaG90b3Nob3A6SUNDUHJvZmlsZT0ic1JHQiBJRUM2MTk2Ni0yLjEiCiAgIGV4aWY6UGl4ZWxYRGltZW5zaW9uPSI2NCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjY0IgogICBleGlmOkNvbG9yU3BhY2U9IjEiCiAgIHRpZmY6SW1hZ2VXaWR0aD0iNjQiCiAgIHRpZmY6SW1hZ2VMZW5ndGg9IjY0IgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIgogICB0aWZmOlhSZXNvbHV0aW9uPSI3Mi8xIgogICB0aWZmOllSZXNvbHV0aW9uPSI3Mi8xIj4KICAgPHhtcE1NOkhpc3Rvcnk+CiAgICA8cmRmOlNlcT4KICAgICA8cmRmOmxpCiAgICAgIHN0RXZ0OmFjdGlvbj0icHJvZHVjZWQiCiAgICAgIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkFmZmluaXR5IFBob3RvIDIgMi4zLjEiCiAgICAgIHN0RXZ0OndoZW49IjIwMjQtMDItMDFUMTQ6NDU6MzQrMDk6MDAiLz4KICAgIDwvcmRmOlNlcT4KICAgPC94bXBNTTpIaXN0b3J5PgogIDwvcmRmOkRlc2NyaXB0aW9uPgogPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KPD94cGFja2V0IGVuZD0iciI/PhldI30AAAGBaUNDUHNSR0IgSUVDNjE5NjYtMi4xAAAokXWRu0sDQRCHP6Mh4oOIWlhYBPHRJBIjiDYWEV+gFjGCr+ZyuUuEJB53JyK2gq2gINr4KvQv0FawFgRFEcTaWtFG5ZwzgQQxs+zst7+dGXZnwRPPqFmrKgzZnG3GRqOB2bn5gO8FDxV46aJRUS1jcnokTln7uJdYsduQW6t83L9Wm9QsFSqqhQdVw7SFx4QnVm3D5R3hZjWtJIXPhIOmXFD4ztUTeX5xOZXnL5fNeGwIPA3CgVQJJ0pYTZtZYXk57dnMilq4j/uSOi03My1rm8xWLGKMEiXAOMMM0UcPA+L7CBGhW3aUyQ//5k+xLLmqeIM1TJZIkcYmKOqKVNdk1UXXZGRYc/v/t6+W3hvJV6+LgvfZcd46wLcN31uO83nkON/HUPkEl7li/vIh9L+LvlXU2g/AvwHnV0UtsQsXm9DyaCim8itVyvToOryeQv0cNN1AzUK+Z4VzTh4gvi5fdQ17+9Ap8f7FHyc6Z8kcDq1+AAAACXBIWXMAAAsTAAALEwEAmpwYAAADwElEQVR4nO2bT4hWVRjGf75TkhoEkhSa/9ocRIIwCsrE1pVnLbkYdFdGgQRS6caVm3CVy2oRuqmQ2yJXKTJh4GqCGs/CJCcLccAJ/yDpnGnxHYeZ4TrNfOc55y78nuWdc3/ve57v+b65f86BgQaqotiE5bEJKxYx7onYhOU1egKwGkViE/YCN4Cx2ITNC4xbDVwAJmMT9tXobVnpArEJe4CvZx0aB7aZdxPzxhkwArw66/Ae8+5Eyf6KJiA2YRPw+bzD64EjLcP3MXfyAMdjEzYWaG1GxRIQmzAEnAVeb/nzFPCSeTeaxj4FBOCZlrEjwBvm3VSJPksm4BPaJw8wBHwXm/BibMIW4HvaJ09ifFygP6BQAtKkfgEeEyHvAy+YdxdFvBmVSsBBdJMnsQ4KeTOSJyA2YT1wCXhcjL4HPG/e/amElkjAAfSTJzEPqKHSBKQLmSvAKiV3lm4BG8y7GyqgOgHvU27yAE+mGjLJEhCbsBL4A3haxXyIJoCN5t0dBUyZgF2UnzypxtsqmNKAt4SsarUkX4F0I3ONOgkAuA48a97FXJAqAa9Qb/IAa4CXFSCVATXjL635yBuQ/RsQm7AWuCroZamaBtaZd3/nQBQJeFPA6EfLFLUVBrwmYPSr7bkAhQHPCRj9al0uQGHAWgGjs9oKA7I/hS5rZ/0XSC86JDclGVph3t3t9+TcBHT56T9QVg+5BnT5/X+grB4GCcgs/sgnYCjzfIWyesg14Hrm+Qpl9ZBrwMT/DymurB4GCeiyuEidGnCN3n15V5pOPfStLAPMu1vAWA4jU7+Zd7dzAIqboREBo7PaCgN+EjA6qz1IQDbAu9/prQeorUvm3eVciOqx+JcizlL0hQKiMuAreiu/amkq1cyWxADz7ipwWsFapH4w7/5SgJRvh+cviCyp4yqQeonMOWCHktmic+bdThVMvUSmyFK2kjWkBph354FTSuY8nTLvflYCSyyT+xD4pwB3EvhADZUbYN5dAfarucB+825cDS25WvwksFuEO2nevSNizVHJ1eLvAoplrePAewJOq4oZYN5NAsPkPTCZBoYTq4iK7hgx734EjmUgjpl3Z1T9tKnGpqlP6e+p0Vg6t6iKG5De3A6ztJul+/Si3/db38WqyrY58+4CcHQJpxxN5xRXFQOSjgCjixg3SvuusiKqZoB59y+964KbCwy7Cew27+7V6apuAkibnhbaEbq3xMaohVTVAADz7hvgMHN/FKeAQ+bdt7X7Kb519mGKTdgKfEbvYucj8+7XLvr4DxAA134c0w/5AAAAAElFTkSuQmCC'; + this.size = 10; + this.density = 1 / 280; + } + + const canvas = this.initCanvas(); + const gl = canvas.getContext('webgl2', { antialias: true }); + if (gl == null) throw new Error('Failed to get WebGL context'); + + document.body.append(canvas); + + this.canvas = canvas; + this.gl = gl; + this.program = this.initProgram(); + this.buffers = this.initBuffers(); + this.uniforms = this.initUniforms(); + this.texture = this.initTexture(); + this.camera = this.initCamera(); + this.wind = this.initWind(); + + this.resize = this.resize.bind(this); + this.update = this.update.bind(this); + + window.addEventListener('resize', () => this.resize()); + } + + private initCanvas(): HTMLCanvasElement { + const canvas = document.createElement('canvas'); + + Object.assign(canvas.style, { + position: 'fixed', + top: 0, + left: 0, + width: '100vw', + height: '100vh', + background: 'transparent', + 'pointer-events': 'none', + 'z-index': 2147483647, + }); + + return canvas; + } + + private initCamera() { + return { ...this.CAMERA }; + } + + private initWind() { + return { ...this.WIND }; + } + + private initShader(type, source): WebGLShader { + const { gl } = this; + const shader = gl.createShader(type); + if (shader == null) throw new Error('Failed to create shader'); + + gl.shaderSource(shader, source); + gl.compileShader(shader); + + return shader; + } + + private initProgram(): WebGLProgram { + const { gl } = this; + const vertex = this.initShader(gl.VERTEX_SHADER, this.VERTEX_SOURCE); + const fragment = this.initShader(gl.FRAGMENT_SHADER, this.FRAGMENT_SOURCE); + const program = gl.createProgram(); + if (program == null) throw new Error('Failed to create program'); + + gl.attachShader(program, vertex); + gl.attachShader(program, fragment); + gl.linkProgram(program); + gl.useProgram(program); + + return program; + } + + private initBuffers(): SnowfallEffect['buffers'] { + const { gl, program } = this; + const buffers = this.INITIAL_BUFFERS() as unknown as SnowfallEffect['buffers']; + + for (const [name, buffer] of Object.entries(buffers)) { + buffer.location = gl.getAttribLocation(program, `a_${name}`); + buffer.ref = gl.createBuffer()!; + + gl.bindBuffer(gl.ARRAY_BUFFER, buffer.ref); + gl.enableVertexAttribArray(buffer.location); + gl.vertexAttribPointer( + buffer.location, + buffer.size, + gl.FLOAT, + false, + 0, + 0, + ); + } + + return buffers; + } + + private updateBuffers() { + const { buffers } = this; + + for (const name of Object.keys(buffers)) { + this.setBuffer(name); + } + } + + private setBuffer(name: string, value?) { + const { gl, buffers } = this; + const buffer = buffers[name]; + + buffer.value = new Float32Array(value ?? buffer.value); + + gl.bindBuffer(gl.ARRAY_BUFFER, buffer.ref); + gl.bufferData(gl.ARRAY_BUFFER, buffer.value, gl.STATIC_DRAW); + } + + private initUniforms(): SnowfallEffect['uniforms'] { + const { gl, program } = this; + const uniforms = this.INITIAL_UNIFORMS() as unknown as SnowfallEffect['uniforms']; + + for (const [name, uniform] of Object.entries(uniforms)) { + uniform.location = gl.getUniformLocation(program, `u_${name}`)!; + } + + return uniforms; + } + + private updateUniforms() { + const { uniforms } = this; + + for (const name of Object.keys(uniforms)) { + this.setUniform(name); + } + } + + private setUniform(name: string, value?) { + const { gl, uniforms } = this; + const uniform = uniforms[name]; + const setter = this.UNIFORM_SETTERS[uniform.type]; + const isMatrix = /^mat[2-4]$/i.test(uniform.type); + + uniform.value = value ?? uniform.value; + + if (isMatrix) { + gl[setter](uniform.location, false, uniform.value); + } else { + gl[setter](uniform.location, uniform.value); + } + } + + private initTexture() { + const { gl } = this; + const texture = gl.createTexture(); + if (texture == null) throw new Error('Failed to create texture'); + const image = new Image(); + + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + 1, + 1, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + new Uint8Array([0, 0, 0, 0]), + ); + + image.onload = () => { + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + gl.RGBA, + gl.UNSIGNED_BYTE, + image, + ); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + }; + + image.src = this.snowflake; + + return texture; + } + + private initSnowflakes(vw: number, vh: number, dpi: number) { + const position: number[] = []; + const color: number[] = []; + const size: number[] = []; + const rotation: number[] = []; + const speed: number[] = []; + + const height = 1 / this.density; + const width = (vw / vh) * height; + const depth = this.depth; + const count = this.count; + const length = (vw / vh) * count; + + for (let i = 0; i < length; ++i) { + position.push( + -width + Math.random() * width * 2, + -height + Math.random() * height * 2, + Math.random() * depth * 2, + ); + + speed.push(1 + Math.random(), 1 + Math.random(), Math.random() * 10); + + rotation.push( + Math.random() * 2 * Math.PI, + Math.random() * 20, + Math.random() * 10, + ); + + color.push(...this.color, 0.1 + Math.random() * this.opacity); + //size.push((this.size * Math.random() * this.size * vh * dpi) / 1000); + size.push((this.size * vh * dpi) / 1000); + } + + this.setUniform('worldSize', [width, height, depth]); + + this.setBuffer('position', position); + this.setBuffer('color', color); + this.setBuffer('rotation', rotation); + this.setBuffer('size', size); + this.setBuffer('speed', speed); + } + + private setProjection(aspect: number) { + const { camera } = this; + + camera.aspect = aspect; + + const fovRad = (camera.fov * Math.PI) / 180; + const f = Math.tan(Math.PI * 0.5 - 0.5 * fovRad); + const rangeInv = 1.0 / (camera.near - camera.far); + + const m0 = f / camera.aspect; + const m5 = f; + const m10 = (camera.near + camera.far) * rangeInv; + const m11 = -1; + const m14 = camera.near * camera.far * rangeInv * 2 + camera.z; + const m15 = camera.z; + + return [m0, 0, 0, 0, 0, m5, 0, 0, 0, 0, m10, m11, 0, 0, m14, m15]; + } + + public render() { + const { gl } = this; + + gl.enable(gl.BLEND); + gl.enable(gl.CULL_FACE); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE); + gl.disable(gl.DEPTH_TEST); + + this.updateBuffers(); + this.updateUniforms(); + this.resize(true); + + this.time = { + start: window.performance.now(), + previous: window.performance.now(), + }; + + if (this.raf) window.cancelAnimationFrame(this.raf); + this.raf = window.requestAnimationFrame(this.update); + + return this; + } + + private resize(updateSnowflakes = false) { + const { canvas, gl } = this; + const vw = canvas.offsetWidth; + const vh = canvas.offsetHeight; + const aspect = vw / vh; + const dpi = window.devicePixelRatio; + + canvas.width = vw * dpi; + canvas.height = vh * dpi; + + gl.viewport(0, 0, vw * dpi, vh * dpi); + gl.clearColor(0, 0, 0, 0); + + if (updateSnowflakes === true) { + this.initSnowflakes(vw, vh, dpi); + } + + this.setUniform('projection', this.setProjection(aspect)); + } + + private update(timestamp: number) { + const { gl, buffers, wind } = this; + const elapsed = (timestamp - this.time.start) * this.speed; + const delta = timestamp - this.time.previous; + + gl.clear(gl.COLOR_BUFFER_BIT); + gl.drawArrays( + gl.POINTS, + 0, + buffers.position.value.length / buffers.position.size, + ); + + if (Math.random() > 0.995) { + wind.target = + (wind.min + Math.random() * (wind.max - wind.min)) * + (Math.random() > 0.5 ? -1 : 1); + } + + wind.force += (wind.target - wind.force) * wind.easing; + wind.current += wind.force * (delta * 0.2); + + this.setUniform('wind', wind.current); + this.setUniform('time', elapsed); + + this.time.previous = timestamp; + + this.raf = window.requestAnimationFrame(this.update); + } +} diff --git a/packages/frontend/src/utility/sound.ts b/packages/frontend/src/utility/sound.ts new file mode 100644 index 0000000000..436c2b75f0 --- /dev/null +++ b/packages/frontend/src/utility/sound.ts @@ -0,0 +1,258 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { SoundStore } from '@/preferences/def.js'; +import { prefer } from '@/preferences.js'; +import { PREF_DEF } from '@/preferences/def.js'; + +let ctx: AudioContext; +const cache = new Map<string, AudioBuffer>(); +let canPlay = true; + +export const soundsTypes = [ + // 音声なし + null, + + // ドライブの音声 + '_driveFile_', + + // プリインストール + 'syuilo/n-aec', + 'syuilo/n-aec-4va', + 'syuilo/n-aec-4vb', + 'syuilo/n-aec-8va', + 'syuilo/n-aec-8vb', + 'syuilo/n-cea', + 'syuilo/n-cea-4va', + 'syuilo/n-cea-4vb', + 'syuilo/n-cea-8va', + 'syuilo/n-cea-8vb', + 'syuilo/n-eca', + 'syuilo/n-eca-4va', + 'syuilo/n-eca-4vb', + 'syuilo/n-eca-8va', + 'syuilo/n-eca-8vb', + 'syuilo/n-ea', + 'syuilo/n-ea-4va', + 'syuilo/n-ea-4vb', + 'syuilo/n-ea-8va', + 'syuilo/n-ea-8vb', + 'syuilo/n-ea-harmony', + 'syuilo/up', + 'syuilo/down', + 'syuilo/pope1', + 'syuilo/pope2', + 'syuilo/waon', + 'syuilo/popo', + 'syuilo/triple', + 'syuilo/bubble1', + 'syuilo/bubble2', + 'syuilo/poi1', + 'syuilo/poi2', + 'syuilo/pirori', + 'syuilo/pirori-wet', + 'syuilo/pirori-square-wet', + 'syuilo/square-pico', + 'syuilo/reverved', + 'syuilo/ryukyu', + 'syuilo/kick', + 'syuilo/snare', + 'syuilo/queue-jammed', + 'aisha/1', + 'aisha/2', + 'aisha/3', + 'noizenecio/kick_gaba1', + 'noizenecio/kick_gaba2', + 'noizenecio/kick_gaba3', + 'noizenecio/kick_gaba4', + 'noizenecio/kick_gaba5', + 'noizenecio/kick_gaba6', + 'noizenecio/kick_gaba7', +] as const; + +export const operationTypes = [ + 'noteMy', + 'note', + 'notification', + 'reaction', +] as const; + +/** サウンドの種類 */ +export type SoundType = typeof soundsTypes[number]; + +/** スプライトの種類 */ +export type OperationType = typeof operationTypes[number]; + +/** + * 音声を読み込む + * @param url url + * @param options `useCache`: デフォルトは`true` 一度再生した音声はキャッシュする + */ +export async function loadAudio(url: string, options?: { useCache?: boolean; }) { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (ctx == null) { + ctx = new AudioContext(); + + window.addEventListener('beforeunload', () => { + ctx.close(); + }); + } + if (options?.useCache ?? true) { + if (cache.has(url)) { + return cache.get(url) as AudioBuffer; + } + } + + let response: Response; + + try { + response = await fetch(url); + } catch (err) { + return; + } + + const arrayBuffer = await response.arrayBuffer(); + const audioBuffer = await ctx.decodeAudioData(arrayBuffer); + + if (options?.useCache ?? true) { + cache.set(url, audioBuffer); + } + + return audioBuffer; +} + +/** + * 既定のスプライトを再生する + * @param type スプライトの種類を指定 + */ +export function playMisskeySfx(operationType: OperationType) { + const sound = prefer.s[`sound.on.${operationType}`]; + playMisskeySfxFile(sound).then((succeed) => { + if (!succeed && sound.type === '_driveFile_') { + // ドライブファイルが存在しない場合はデフォルトのサウンドを再生する + const soundName = PREF_DEF[`sound_${operationType}`].default.type as Exclude<SoundType, '_driveFile_'>; + if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`); + playMisskeySfxFileInternal({ + type: soundName, + volume: sound.volume, + }); + } + }); +} + +/** + * サウンド設定形式で指定された音声を再生する + * @param soundStore サウンド設定 + */ +export async function playMisskeySfxFile(soundStore: SoundStore): Promise<boolean> { + // 連続して再生しない + if (!canPlay) return false; + // ユーザーアクティベーションが必要な場合はそれがない場合は再生しない + if ('userActivation' in navigator && !navigator.userActivation.hasBeenActive) return false; + // サウンドがない場合は再生しない + if (soundStore.type === null || soundStore.type === '_driveFile_' && !soundStore.fileUrl) return false; + + canPlay = false; + return await playMisskeySfxFileInternal(soundStore).finally(() => { + // ごく短時間に音が重複しないように + setTimeout(() => { + canPlay = true; + }, 25); + }); +} + +async function playMisskeySfxFileInternal(soundStore: SoundStore): Promise<boolean> { + if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) { + return false; + } + const masterVolume = prefer.s['sound.masterVolume']; + if (isMute() || masterVolume === 0 || soundStore.volume === 0) { + return true; // ミュート時は成功として扱う + } + const url = soundStore.type === '_driveFile_' ? soundStore.fileUrl : `/client-assets/sounds/${soundStore.type}.mp3`; + const buffer = await loadAudio(url).catch(() => { + return undefined; + }); + if (!buffer) return false; + const volume = soundStore.volume * masterVolume; + createSourceNode(buffer, { volume }).soundSource.start(); + return true; +} + +export async function playUrl(url: string, opts: { + volume?: number; + pan?: number; + playbackRate?: number; +}) { + if (opts.volume === 0) { + return; + } + const buffer = await loadAudio(url); + if (!buffer) return; + createSourceNode(buffer, opts).soundSource.start(); +} + +export function createSourceNode(buffer: AudioBuffer, opts: { + volume?: number; + pan?: number; + playbackRate?: number; +}): { + soundSource: AudioBufferSourceNode; + panNode: StereoPannerNode; + gainNode: GainNode; + } { + const panNode = ctx.createStereoPanner(); + panNode.pan.value = opts.pan ?? 0; + + const gainNode = ctx.createGain(); + + gainNode.gain.value = opts.volume ?? 1; + + const soundSource = ctx.createBufferSource(); + soundSource.buffer = buffer; + soundSource.playbackRate.value = opts.playbackRate ?? 1; + soundSource + .connect(panNode) + .connect(gainNode) + .connect(ctx.destination); + + return { soundSource, panNode, gainNode }; +} + +/** + * 音声の長さをミリ秒で取得する + * @param file ファイルのURL(ドライブIDではない) + */ +export async function getSoundDuration(file: string): Promise<number> { + const audioEl = document.createElement('audio'); + audioEl.src = file; + return new Promise((resolve) => { + const si = setInterval(() => { + if (audioEl.readyState > 0) { + resolve(audioEl.duration * 1000); + clearInterval(si); + audioEl.remove(); + } + }, 100); + }); +} + +/** + * ミュートすべきかどうかを判断する + */ +export function isMute(): boolean { + if (prefer.s['sound.notUseSound']) { + // サウンドを出力しない + return true; + } + + // noinspection RedundantIfStatementJS + if (prefer.s['sound.useSoundOnlyWhenActive'] && document.visibilityState === 'hidden') { + // ブラウザがアクティブな時のみサウンドを出力する + return true; + } + + return false; +} diff --git a/packages/frontend/src/utility/sticky-sidebar.ts b/packages/frontend/src/utility/sticky-sidebar.ts new file mode 100644 index 0000000000..50f1e6ecc8 --- /dev/null +++ b/packages/frontend/src/utility/sticky-sidebar.ts @@ -0,0 +1,55 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class StickySidebar { + private lastScrollTop = 0; + private container: HTMLElement; + private el: HTMLElement; + private spacer: HTMLElement; + private marginTop: number; + private isTop = false; + private isBottom = false; + private offsetTop: number; + private globalHeaderHeight = 59; + + constructor(container: StickySidebar['container'], marginTop = 0, globalHeaderHeight = 0) { + this.container = container; + this.el = this.container.children[0] as HTMLElement; + this.el.style.position = 'sticky'; + this.spacer = document.createElement('div'); + this.container.prepend(this.spacer); + this.marginTop = marginTop; + this.offsetTop = this.container.getBoundingClientRect().top; + this.globalHeaderHeight = globalHeaderHeight; + } + + public calc(scrollTop: number) { + if (scrollTop > this.lastScrollTop) { // downscroll + const overflow = Math.max(0, this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight); + this.el.style.bottom = null; + this.el.style.top = `${-overflow + this.marginTop + this.globalHeaderHeight}px`; + + this.isBottom = (scrollTop + window.innerHeight) >= (this.el.offsetTop + this.el.clientHeight); + + if (this.isTop) { + this.isTop = false; + this.spacer.style.marginTop = `${Math.max(0, this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop)}px`; + } + } else { // upscroll + const overflow = this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight; + this.el.style.top = null; + this.el.style.bottom = `${-overflow}px`; + + this.isTop = scrollTop + this.marginTop + this.globalHeaderHeight <= this.el.offsetTop; + + if (this.isBottom) { + this.isBottom = false; + this.spacer.style.marginTop = `${this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop - overflow}px`; + } + } + + this.lastScrollTop = scrollTop <= 0 ? 0 : scrollTop; + } +} diff --git a/packages/frontend/src/utility/stream-mock.ts b/packages/frontend/src/utility/stream-mock.ts new file mode 100644 index 0000000000..9b1b368de4 --- /dev/null +++ b/packages/frontend/src/utility/stream-mock.ts @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { EventEmitter } from 'eventemitter3'; +import * as Misskey from 'misskey-js'; +import type { Channels, StreamEvents, IStream, IChannelConnection } from 'misskey-js'; + +type AnyOf<T extends Record<any, any>> = T[keyof T]; +type OmitFirst<T extends any[]> = T extends [any, ...infer R] ? R : never; + +/** + * Websocket無効化時に使うStreamのモック(なにもしない) + */ +export class StreamMock extends EventEmitter<StreamEvents> implements IStream { + public readonly state = 'initializing'; + + constructor(...args: ConstructorParameters<typeof Misskey.Stream>) { + super(); + // do nothing + } + + public useChannel<C extends keyof Channels>(channel: C, params?: Channels[C]['params'], name?: string): ChannelConnectionMock<Channels[C]> { + return new ChannelConnectionMock(this, channel, name); + } + + public removeSharedConnection(connection: any): void { + // do nothing + } + + public removeSharedConnectionPool(pool: any): void { + // do nothing + } + + public disconnectToChannel(): void { + // do nothing + } + + public send(typeOrPayload: string): void; + public send(typeOrPayload: string, payload: any): void; + public send(typeOrPayload: Record<string, any> | any[]): void; + public send(typeOrPayload: string | Record<string, any> | any[], payload?: any): void { + // do nothing + } + + public ping(): void { + // do nothing + } + + public heartbeat(): void { + // do nothing + } + + public close(): void { + // do nothing + } +} + +class ChannelConnectionMock<Channel extends AnyOf<Channels> = any> extends EventEmitter<Channel['events']> implements IChannelConnection<Channel> { + public id = ''; + public name?: string; // for debug + public inCount = 0; // for debug + public outCount = 0; // for debug + public channel: string; + + constructor(stream: IStream, ...args: OmitFirst<ConstructorParameters<typeof Misskey.ChannelConnection<Channel>>>) { + super(); + + this.channel = args[0]; + this.name = args[1]; + } + + public send<T extends keyof Channel['receives']>(type: T, body: Channel['receives'][T]): void { + // do nothing + } + + public dispose(): void { + // do nothing + } +} diff --git a/packages/frontend/src/utility/test-utils.ts b/packages/frontend/src/utility/test-utils.ts new file mode 100644 index 0000000000..52bb2d94e0 --- /dev/null +++ b/packages/frontend/src/utility/test-utils.ts @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export async function tick(): Promise<void> { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + await new Promise((globalThis.requestIdleCallback ?? setTimeout) as never); +} diff --git a/packages/frontend/src/utility/theme-editor.ts b/packages/frontend/src/utility/theme-editor.ts new file mode 100644 index 0000000000..0206e378bf --- /dev/null +++ b/packages/frontend/src/utility/theme-editor.ts @@ -0,0 +1,87 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { v4 as uuid } from 'uuid'; + +import { themeProps } from './theme.js'; +import type { Theme } from './theme.js'; + +export type Default = null; +export type Color = string; +export type FuncName = 'alpha' | 'darken' | 'lighten'; +export type Func = { type: 'func'; name: FuncName; arg: number; value: string; }; +export type RefProp = { type: 'refProp'; key: string; }; +export type RefConst = { type: 'refConst'; key: string; }; +export type Css = { type: 'css'; value: string; }; + +export type ThemeValue = Color | Func | RefProp | RefConst | Css | Default; + +export type ThemeViewModel = [ string, ThemeValue ][]; + +export const fromThemeString = (str?: string) : ThemeValue => { + if (!str) return null; + if (str.startsWith(':')) { + const parts = str.slice(1).split('<'); + const name = parts[0] as FuncName; + const arg = parseFloat(parts[1]); + const value = parts[2].startsWith('@') ? parts[2].slice(1) : ''; + return { type: 'func', name, arg, value }; + } else if (str.startsWith('@')) { + return { + type: 'refProp', + key: str.slice(1), + }; + } else if (str.startsWith('$')) { + return { + type: 'refConst', + key: str.slice(1), + }; + } else if (str.startsWith('"')) { + return { + type: 'css', + value: str.substring(1).trim(), + }; + } else { + return str; + } +}; + +export const toThemeString = (value: Color | Func | RefProp | RefConst | Css) => { + if (typeof value === 'string') return value; + switch (value.type) { + case 'func': return `:${value.name}<${value.arg}<@${value.value}`; + case 'refProp': return `@${value.key}`; + case 'refConst': return `$${value.key}`; + case 'css': return `" ${value.value}`; + } +}; + +export const convertToMisskeyTheme = (vm: ThemeViewModel, name: string, desc: string, author: string, base: 'dark' | 'light'): Theme => { + const props = { } as { [key: string]: string }; + for (const [key, value] of vm) { + if (value === null) continue; + props[key] = toThemeString(value); + } + + return { + id: uuid(), + name, desc, author, props, base, + }; +}; + +export const convertToViewModel = (theme: Theme): ThemeViewModel => { + const vm: ThemeViewModel = []; + // プロパティの登録 + vm.push(...themeProps.map(key => [key, fromThemeString(theme.props[key])] as [ string, ThemeValue ])); + + // 定数の登録 + const consts = Object + .keys(theme.props) + .filter(k => k.startsWith('$')) + .map(k => [k, fromThemeString(theme.props[k])] as [ string, ThemeValue ]); + + vm.push(...consts); + return vm; +}; diff --git a/packages/frontend/src/utility/theme.ts b/packages/frontend/src/utility/theme.ts new file mode 100644 index 0000000000..851ba41e61 --- /dev/null +++ b/packages/frontend/src/utility/theme.ts @@ -0,0 +1,189 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ref } from 'vue'; +import tinycolor from 'tinycolor2'; +import lightTheme from '@@/themes/_light.json5'; +import darkTheme from '@@/themes/_dark.json5'; +import JSON5 from 'json5'; +import { deepClone } from './clone.js'; +import type { BundledTheme } from 'shiki/themes'; +import { globalEvents } from '@/events.js'; +import { miLocalStorage } from '@/local-storage.js'; +import { addTheme, getThemes } from '@/theme-store.js'; + +export type Theme = { + id: string; + name: string; + author: string; + desc?: string; + base?: 'dark' | 'light'; + props: Record<string, string>; + codeHighlighter?: { + base: BundledTheme; + overrides?: Record<string, any>; + } | { + base: '_none_'; + overrides: Record<string, any>; + }; +}; + +export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X')); + +export const getBuiltinThemes = () => Promise.all( + [ + 'l-light', + 'l-coffee', + 'l-apricot', + 'l-rainy', + 'l-botanical', + 'l-vivid', + 'l-cherry', + 'l-sushi', + 'l-u0', + + 'd-dark', + 'd-persimmon', + 'd-astro', + 'd-future', + 'd-botanical', + 'd-green-lime', + 'd-green-orange', + 'd-cherry', + 'd-ice', + 'd-u0', + ].map(name => import(`@@/themes/${name}.json5`).then(({ default: _default }): Theme => _default)), +); + +export const getBuiltinThemesRef = () => { + const builtinThemes = ref<Theme[]>([]); + getBuiltinThemes().then(themes => builtinThemes.value = themes); + return builtinThemes; +}; + +let timeout: number | null = null; + +export function applyTheme(theme: Theme, persist = true) { + if (timeout) window.clearTimeout(timeout); + + document.documentElement.classList.add('_themeChanging_'); + + timeout = window.setTimeout(() => { + document.documentElement.classList.remove('_themeChanging_'); + }, 1000); + + const colorScheme = theme.base === 'dark' ? 'dark' : 'light'; + + document.documentElement.dataset.colorScheme = colorScheme; + + // Deep copy + const _theme = deepClone(theme); + + if (_theme.base) { + const base = [lightTheme, darkTheme].find(x => x.id === _theme.base); + if (base) _theme.props = Object.assign({}, base.props, _theme.props); + } + + const props = compile(_theme); + + for (const tag of document.head.children) { + if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { + tag.setAttribute('content', props['htmlThemeColor']); + break; + } + } + + for (const [k, v] of Object.entries(props)) { + document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString()); + } + + document.documentElement.style.setProperty('color-scheme', colorScheme); + + if (persist) { + miLocalStorage.setItem('theme', JSON.stringify(props)); + miLocalStorage.setItem('themeId', theme.id); + miLocalStorage.setItem('colorScheme', colorScheme); + } + + // 色計算など再度行えるようにクライアント全体に通知 + globalEvents.emit('themeChanged'); +} + +function compile(theme: Theme): Record<string, string> { + function getColor(val: string): tinycolor.Instance { + if (val[0] === '@') { // ref (prop) + return getColor(theme.props[val.substring(1)]); + } else if (val[0] === '$') { // ref (const) + return getColor(theme.props[val]); + } else if (val[0] === ':') { // func + const parts = val.split('<'); + const func = parts.shift().substring(1); + const arg = parseFloat(parts.shift()); + const color = getColor(parts.join('<')); + + switch (func) { + case 'darken': return color.darken(arg); + case 'lighten': return color.lighten(arg); + case 'alpha': return color.setAlpha(arg); + case 'hue': return color.spin(arg); + case 'saturate': return color.saturate(arg); + } + } + + // other case + return tinycolor(val); + } + + const props = {}; + + for (const [k, v] of Object.entries(theme.props)) { + if (k.startsWith('$')) continue; // ignore const + + props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v)); + } + + return props; +} + +function genValue(c: tinycolor.Instance): string { + return c.toRgbString(); +} + +export function validateTheme(theme: Record<string, any>): boolean { + if (theme.id == null || typeof theme.id !== 'string') return false; + if (theme.name == null || typeof theme.name !== 'string') return false; + if (theme.base == null || !['light', 'dark'].includes(theme.base)) return false; + if (theme.props == null || typeof theme.props !== 'object') return false; + return true; +} + +export function parseThemeCode(code: string): Theme { + let theme; + + try { + theme = JSON5.parse(code); + } catch (err) { + throw new Error('Failed to parse theme json'); + } + if (!validateTheme(theme)) { + throw new Error('This theme is invaild'); + } + if (getThemes().some(t => t.id === theme.id)) { + throw new Error('This theme is already installed'); + } + + return theme; +} + +export function previewTheme(code: string): void { + const theme = parseThemeCode(code); + if (theme) applyTheme(theme, false); +} + +export async function installTheme(code: string): Promise<void> { + const theme = parseThemeCode(code); + if (!theme) return; + await addTheme(theme); +} diff --git a/packages/frontend/src/utility/time.ts b/packages/frontend/src/utility/time.ts new file mode 100644 index 0000000000..275b67ed00 --- /dev/null +++ b/packages/frontend/src/utility/time.ts @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +const dateTimeIntervals = { + 'day': 86400000, + 'hour': 3600000, + 'ms': 1, +}; + +export function dateUTC(time: number[]): Date { + const d = + time.length === 2 ? Date.UTC(time[0], time[1]) + : time.length === 3 ? Date.UTC(time[0], time[1], time[2]) + : time.length === 4 ? Date.UTC(time[0], time[1], time[2], time[3]) + : time.length === 5 ? Date.UTC(time[0], time[1], time[2], time[3], time[4]) + : time.length === 6 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5]) + : time.length === 7 ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6]) + : null; + + if (!d) throw new Error('wrong number of arguments'); + + return new Date(d); +} + +export function isTimeSame(a: Date, b: Date): boolean { + return a.getTime() === b.getTime(); +} + +export function isTimeBefore(a: Date, b: Date): boolean { + return (a.getTime() - b.getTime()) < 0; +} + +export function isTimeAfter(a: Date, b: Date): boolean { + return (a.getTime() - b.getTime()) > 0; +} + +export function addTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date { + return new Date(x.getTime() + (value * dateTimeIntervals[span])); +} + +export function subtractTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date { + return new Date(x.getTime() - (value * dateTimeIntervals[span])); +} diff --git a/packages/frontend/src/utility/timezones.ts b/packages/frontend/src/utility/timezones.ts new file mode 100644 index 0000000000..c7582e06da --- /dev/null +++ b/packages/frontend/src/utility/timezones.ts @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export const timezones = [{ + name: 'UTC', + abbrev: 'UTC', + offset: 0, +}, { + name: 'Europe/Berlin', + abbrev: 'CET', + offset: 60, +}, { + name: 'Asia/Tokyo', + abbrev: 'JST', + offset: 540, +}, { + name: 'Asia/Seoul', + abbrev: 'KST', + offset: 540, +}, { + name: 'Asia/Shanghai', + abbrev: 'CST', + offset: 480, +}, { + name: 'Australia/Sydney', + abbrev: 'AEST', + offset: 600, +}, { + name: 'Australia/Darwin', + abbrev: 'ACST', + offset: 570, +}, { + name: 'Australia/Perth', + abbrev: 'AWST', + offset: 480, +}, { + name: 'America/New_York', + abbrev: 'EST', + offset: -300, +}, { + name: 'America/Mexico_City', + abbrev: 'CST', + offset: -360, +}, { + name: 'America/Phoenix', + abbrev: 'MST', + offset: -420, +}, { + name: 'America/Los_Angeles', + abbrev: 'PST', + offset: -480, +}]; diff --git a/packages/frontend/src/utility/touch.ts b/packages/frontend/src/utility/touch.ts new file mode 100644 index 0000000000..adc2e4c093 --- /dev/null +++ b/packages/frontend/src/utility/touch.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ref } from 'vue'; +import { deviceKind } from '@/utility/device-kind.js'; + +const isTouchSupported = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0; + +export let isTouchUsing = deviceKind === 'tablet' || deviceKind === 'smartphone'; + +if (isTouchSupported && !isTouchUsing) { + window.addEventListener('touchstart', () => { + // maxTouchPointsなどでの判定だけだと、「タッチ機能付きディスプレイを使っているがマウスでしか操作しない」場合にも + // タッチで使っていると判定されてしまうため、実際に一度でもタッチされたらtrueにする + isTouchUsing = true; + }, { passive: true }); +} + +/** (MkHorizontalSwipe) 横スワイプ中か? */ +export const isHorizontalSwipeSwiping = ref(false); diff --git a/packages/frontend/src/utility/unison-reload.ts b/packages/frontend/src/utility/unison-reload.ts new file mode 100644 index 0000000000..a24941d02e --- /dev/null +++ b/packages/frontend/src/utility/unison-reload.ts @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +// SafariがBroadcastChannel未実装なのでライブラリを使う +import { BroadcastChannel } from 'broadcast-channel'; + +export const reloadChannel = new BroadcastChannel<string | null>('reload'); + +// BroadcastChannelを用いて、クライアントが一斉にreloadするようにします。 +export function unisonReload(path?: string) { + if (path !== undefined) { + reloadChannel.postMessage(path); + location.href = path; + } else { + reloadChannel.postMessage(null); + location.reload(); + } +} diff --git a/packages/frontend/src/utility/upload.ts b/packages/frontend/src/utility/upload.ts new file mode 100644 index 0000000000..d105a318a7 --- /dev/null +++ b/packages/frontend/src/utility/upload.ts @@ -0,0 +1,162 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { reactive, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { v4 as uuid } from 'uuid'; +import { readAndCompressImage } from '@misskey-dev/browser-image-resizer'; +import { apiUrl } from '@@/js/config.js'; +import { getCompressionConfig } from './upload/compress-config.js'; +import { $i } from '@/account.js'; +import { alert } from '@/os.js'; +import { i18n } from '@/i18n.js'; +import { instance } from '@/instance.js'; +import { prefer } from '@/preferences.js'; + +type Uploading = { + id: string; + name: string; + progressMax: number | undefined; + progressValue: number | undefined; + img: string; +}; +export const uploads = ref<Uploading[]>([]); + +const mimeTypeMap = { + 'image/webp': 'webp', + 'image/jpeg': 'jpg', + 'image/png': 'png', +} as const; + +export function uploadFile( + file: File, + folder?: string | Misskey.entities.DriveFolder, + name?: string, + keepOriginal: boolean = prefer.s.keepOriginalUploading, +): Promise<Misskey.entities.DriveFile> { + if ($i == null) throw new Error('Not logged in'); + + const _folder = typeof folder === 'string' ? folder : folder?.id; + + if (file.size > instance.maxFileSize) { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, + }); + return Promise.reject(); + } + + return new Promise((resolve, reject) => { + const id = uuid(); + + const reader = new FileReader(); + reader.onload = async (): Promise<void> => { + const filename = name ?? file.name ?? 'untitled'; + const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : ''; + + const ctx = reactive<Uploading>({ + id, + name: prefer.s.keepOriginalFilename ? filename : id + extension, + progressMax: undefined, + progressValue: undefined, + img: window.URL.createObjectURL(file), + }); + + uploads.value.push(ctx); + + const config = !keepOriginal ? await getCompressionConfig(file) : undefined; + let resizedImage: Blob | undefined; + if (config) { + try { + const resized = await readAndCompressImage(file, config); + if (resized.size < file.size || file.type === 'image/webp') { + // The compression may not always reduce the file size + // (and WebP is not browser safe yet) + resizedImage = resized; + } + if (_DEV_) { + const saved = ((1 - resized.size / file.size) * 100).toFixed(2); + console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`); + } + + ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name; + } catch (err) { + console.error('Failed to resize image', err); + } + } + + const formData = new FormData(); + formData.append('i', $i!.token); + formData.append('force', 'true'); + formData.append('file', resizedImage ?? file); + formData.append('name', ctx.name); + if (_folder) formData.append('folderId', _folder); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => { + if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { + // TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい + uploads.value = uploads.value.filter(x => x.id !== id); + + if (xhr.status === 413) { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, + }); + } else if (ev.target?.response) { + const res = JSON.parse(ev.target.response); + if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseInappropriate, + }); + } else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseNoFreeSpace, + }); + } else { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`, + }); + } + } else { + alert({ + type: 'error', + title: 'Failed to upload', + text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, + }); + } + + reject(); + return; + } + + const driveFile = JSON.parse(ev.target.response); + + resolve(driveFile); + + uploads.value = uploads.value.filter(x => x.id !== id); + }) as (ev: ProgressEvent<EventTarget>) => any; + + xhr.upload.onprogress = ev => { + if (ev.lengthComputable) { + ctx.progressMax = ev.total; + ctx.progressValue = ev.loaded; + } + }; + + xhr.send(formData); + }; + reader.readAsArrayBuffer(file); + }); +} diff --git a/packages/frontend/src/utility/upload/compress-config.ts b/packages/frontend/src/utility/upload/compress-config.ts new file mode 100644 index 0000000000..3046b7f518 --- /dev/null +++ b/packages/frontend/src/utility/upload/compress-config.ts @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import isAnimated from 'is-file-animated'; +import { isWebpSupported } from './isWebpSupported.js'; +import type { BrowserImageResizerConfigWithConvertedOutput } from '@misskey-dev/browser-image-resizer'; + +const compressTypeMap = { + 'image/jpeg': { quality: 0.90, mimeType: 'image/webp' }, + 'image/png': { quality: 1, mimeType: 'image/webp' }, + 'image/webp': { quality: 0.90, mimeType: 'image/webp' }, + 'image/svg+xml': { quality: 1, mimeType: 'image/webp' }, +} as const; + +const compressTypeMapFallback = { + 'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' }, + 'image/png': { quality: 1, mimeType: 'image/png' }, + 'image/webp': { quality: 0.85, mimeType: 'image/jpeg' }, + 'image/svg+xml': { quality: 1, mimeType: 'image/png' }, +} as const; + +export async function getCompressionConfig(file: File): Promise<BrowserImageResizerConfigWithConvertedOutput | undefined> { + const imgConfig = (isWebpSupported() ? compressTypeMap : compressTypeMapFallback)[file.type]; + if (!imgConfig || await isAnimated(file)) { + return; + } + + return { + maxWidth: 2048, + maxHeight: 2048, + debug: true, + ...imgConfig, + }; +} diff --git a/packages/frontend/src/utility/upload/isWebpSupported.ts b/packages/frontend/src/utility/upload/isWebpSupported.ts new file mode 100644 index 0000000000..2511236ecc --- /dev/null +++ b/packages/frontend/src/utility/upload/isWebpSupported.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +let isWebpSupportedCache: boolean | undefined; +export function isWebpSupported() { + if (isWebpSupportedCache === undefined) { + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + isWebpSupportedCache = canvas.toDataURL('image/webp').startsWith('data:image/webp'); + } + return isWebpSupportedCache; +} diff --git a/packages/frontend/src/utility/use-chart-tooltip.ts b/packages/frontend/src/utility/use-chart-tooltip.ts new file mode 100644 index 0000000000..bba64fc6ee --- /dev/null +++ b/packages/frontend/src/utility/use-chart-tooltip.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { onUnmounted, onDeactivated, ref } from 'vue'; +import * as os from '@/os.js'; +import MkChartTooltip from '@/components/MkChartTooltip.vue'; + +export function useChartTooltip(opts: { position: 'top' | 'middle' } = { position: 'top' }) { + const tooltipShowing = ref(false); + const tooltipX = ref(0); + const tooltipY = ref(0); + const tooltipTitle = ref<string | null>(null); + const tooltipSeries = ref<{ + backgroundColor: string; + borderColor: string; + text: string; + }[] | null>(null); + const { dispose: disposeTooltipComponent } = os.popup(MkChartTooltip, { + showing: tooltipShowing, + x: tooltipX, + y: tooltipY, + title: tooltipTitle, + series: tooltipSeries, + }, {}); + + onUnmounted(() => { + disposeTooltipComponent(); + }); + + onDeactivated(() => { + tooltipShowing.value = false; + }); + + function handler(context) { + if (context.tooltip.opacity === 0) { + tooltipShowing.value = false; + return; + } + + tooltipTitle.value = context.tooltip.title[0]; + tooltipSeries.value = context.tooltip.body.map((b, i) => ({ + backgroundColor: context.tooltip.labelColors[i].backgroundColor, + borderColor: context.tooltip.labelColors[i].borderColor, + text: b.lines[0], + })); + + const rect = context.chart.canvas.getBoundingClientRect(); + + tooltipShowing.value = true; + tooltipX.value = rect.left + window.scrollX + context.tooltip.caretX; + if (opts.position === 'top') { + tooltipY.value = rect.top + window.scrollY; + } else if (opts.position === 'middle') { + tooltipY.value = rect.top + window.scrollY + context.tooltip.caretY; + } + } + + return { + handler, + }; +} diff --git a/packages/frontend/src/utility/use-form.ts b/packages/frontend/src/utility/use-form.ts new file mode 100644 index 0000000000..26cca839c3 --- /dev/null +++ b/packages/frontend/src/utility/use-form.ts @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { computed, reactive, watch } from 'vue'; +import type { Reactive } from 'vue'; + +function copy<T>(v: T): T { + return JSON.parse(JSON.stringify(v)); +} + +function unwrapReactive<T>(v: Reactive<T>): T { + return JSON.parse(JSON.stringify(v)); +} + +export function useForm<T extends Record<string, any>>(initialState: T, save: (newState: T) => Promise<void>) { + const currentState = reactive<T>(copy(initialState)); + const previousState = reactive<T>(copy(initialState)); + + const modifiedStates = reactive<Record<keyof T, boolean>>({} as any); + for (const key in currentState) { + modifiedStates[key] = false; + } + const modified = computed(() => Object.values(modifiedStates).some(v => v)); + const modifiedCount = computed(() => Object.values(modifiedStates).filter(v => v).length); + + watch([currentState, previousState], () => { + for (const key in modifiedStates) { + modifiedStates[key] = currentState[key] !== previousState[key]; + } + }, { deep: true }); + + async function _save() { + await save(unwrapReactive(currentState)); + for (const key in currentState) { + previousState[key] = copy(currentState[key]); + } + } + + function discard() { + for (const key in currentState) { + currentState[key] = copy(previousState[key]); + } + } + + return { + state: currentState, + savedState: previousState, + modifiedStates, + modified, + modifiedCount, + save: _save, + discard, + }; +} diff --git a/packages/frontend/src/utility/use-leave-guard.ts b/packages/frontend/src/utility/use-leave-guard.ts new file mode 100644 index 0000000000..395c12a756 --- /dev/null +++ b/packages/frontend/src/utility/use-leave-guard.ts @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { Ref } from 'vue'; + +export function useLeaveGuard(enabled: Ref<boolean>) { + /* TODO + const setLeaveGuard = inject('setLeaveGuard'); + + if (setLeaveGuard) { + setLeaveGuard(async () => { + if (!enabled.value) return false; + + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.leaveConfirm, + }); + + return canceled; + }); + } else { + onBeforeRouteLeave(async (to, from) => { + if (!enabled.value) return true; + + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.leaveConfirm, + }); + + return !canceled; + }); + } + */ + + /* + function onBeforeLeave(ev: BeforeUnloadEvent) { + if (enabled.value) { + ev.preventDefault(); + ev.returnValue = ''; + } + } + + window.addEventListener('beforeunload', onBeforeLeave); + onUnmounted(() => { + window.removeEventListener('beforeunload', onBeforeLeave); + }); + */ +} diff --git a/packages/frontend/src/utility/use-note-capture.ts b/packages/frontend/src/utility/use-note-capture.ts new file mode 100644 index 0000000000..0bc10e90e4 --- /dev/null +++ b/packages/frontend/src/utility/use-note-capture.ts @@ -0,0 +1,124 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { onUnmounted } from 'vue'; +import type { Ref, ShallowRef } from 'vue'; +import * as Misskey from 'misskey-js'; +import { useStream } from '@/stream.js'; +import { $i } from '@/account.js'; + +export function useNoteCapture(props: { + rootEl: ShallowRef<HTMLElement | undefined>; + note: Ref<Misskey.entities.Note>; + pureNote: Ref<Misskey.entities.Note>; + isDeletedRef: Ref<boolean>; +}) { + const note = props.note; + const pureNote = props.pureNote; + const connection = $i ? useStream() : null; + + function onStreamNoteUpdated(noteData): void { + const { type, id, body } = noteData; + + if ((id !== note.value.id) && (id !== pureNote.value.id)) return; + + switch (type) { + case 'reacted': { + const reaction = body.reaction; + + if (body.emoji && !(body.emoji.name in note.value.reactionEmojis)) { + note.value.reactionEmojis[body.emoji.name] = body.emoji.url; + } + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (note.value.reactions || {})[reaction] || 0; + + note.value.reactions[reaction] = currentCount + 1; + note.value.reactionCount += 1; + + if ($i && (body.userId === $i.id)) { + note.value.myReaction = reaction; + } + break; + } + + case 'unreacted': { + const reaction = body.reaction; + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (note.value.reactions || {})[reaction] || 0; + + note.value.reactions[reaction] = Math.max(0, currentCount - 1); + note.value.reactionCount = Math.max(0, note.value.reactionCount - 1); + if (note.value.reactions[reaction] === 0) delete note.value.reactions[reaction]; + + if ($i && (body.userId === $i.id)) { + note.value.myReaction = null; + } + break; + } + + case 'pollVoted': { + const choice = body.choice; + + const choices = [...note.value.poll.choices]; + choices[choice] = { + ...choices[choice], + votes: choices[choice].votes + 1, + ...($i && (body.userId === $i.id) ? { + isVoted: true, + } : {}), + }; + + note.value.poll.choices = choices; + break; + } + + case 'deleted': { + props.isDeletedRef.value = true; + break; + } + } + } + + function capture(withHandler = false): void { + if (connection) { + // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する + connection.send(document.body.contains(props.rootEl.value ?? null as Node | null) ? 'sr' : 's', { id: note.value.id }); + if (pureNote.value.id !== note.value.id) connection.send('s', { id: pureNote.value.id }); + if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated); + } + } + + function decapture(withHandler = false): void { + if (connection) { + connection.send('un', { + id: note.value.id, + }); + if (pureNote.value.id !== note.value.id) { + connection.send('un', { + id: pureNote.value.id, + }); + } + if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated); + } + } + + function onStreamConnected() { + capture(false); + } + + capture(true); + if (connection) { + connection.on('_connected_', onStreamConnected); + } + + onUnmounted(() => { + decapture(true); + if (connection) { + connection.off('_connected_', onStreamConnected); + } + }); +} diff --git a/packages/frontend/src/utility/use-tooltip.ts b/packages/frontend/src/utility/use-tooltip.ts new file mode 100644 index 0000000000..d9ddfc8b5d --- /dev/null +++ b/packages/frontend/src/utility/use-tooltip.ts @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { ref, watch, onUnmounted } from 'vue'; +import type { Ref } from 'vue'; + +export function useTooltip( + elRef: Ref<HTMLElement | { $el: HTMLElement } | null | undefined>, + onShow: (showing: Ref<boolean>) => void, + delay = 300, +): void { + let isHovering = false; + + // iOS(Androidも?)では、要素をタップした直後に(おせっかいで)mouseoverイベントを発火させたりするため、それを無視するためのフラグ + // 無視しないと、画面に触れてないのにツールチップが出たりし、ユーザビリティが損なわれる + // TODO: 一度でもタップすると二度とマウスでツールチップ出せなくなるのをどうにかする 定期的にfalseに戻すとか...? + let shouldIgnoreMouseover = false; + + let timeoutId: number; + + let changeShowingState: (() => void) | null; + + let autoHidingTimer; + + const open = () => { + close(); + if (!isHovering) return; + if (elRef.value == null) return; + const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el; + if (!document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため + + const showing = ref(true); + onShow(showing); + changeShowingState = () => { + showing.value = false; + }; + + autoHidingTimer = window.setInterval(() => { + if (elRef.value == null || !document.body.contains(elRef.value instanceof Element ? elRef.value : elRef.value.$el)) { + if (!isHovering) return; + isHovering = false; + window.clearTimeout(timeoutId); + close(); + window.clearInterval(autoHidingTimer); + } + }, 1000); + }; + + const close = () => { + if (changeShowingState != null) { + changeShowingState(); + changeShowingState = null; + } + }; + + const onMouseover = () => { + if (isHovering) return; + if (shouldIgnoreMouseover) return; + isHovering = true; + timeoutId = window.setTimeout(open, delay); + }; + + const onMouseleave = () => { + if (!isHovering) return; + isHovering = false; + window.clearTimeout(timeoutId); + window.clearInterval(autoHidingTimer); + close(); + }; + + const onTouchstart = () => { + shouldIgnoreMouseover = true; + if (isHovering) return; + isHovering = true; + timeoutId = window.setTimeout(open, delay); + }; + + const onTouchend = () => { + if (!isHovering) return; + isHovering = false; + window.clearTimeout(timeoutId); + window.clearInterval(autoHidingTimer); + close(); + }; + + const stop = watch(elRef, () => { + if (elRef.value) { + stop(); + const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el; + el.addEventListener('mouseover', onMouseover, { passive: true }); + el.addEventListener('mouseleave', onMouseleave, { passive: true }); + el.addEventListener('touchstart', onTouchstart, { passive: true }); + el.addEventListener('touchend', onTouchend, { passive: true }); + el.addEventListener('click', close, { passive: true }); + } + }, { + immediate: true, + flush: 'post', + }); + + onUnmounted(() => { + close(); + }); +} |