merge upstream

This commit is contained in:
Hazelnoot 2025-03-25 16:14:53 -04:00
commit d8908ef2d8
1065 changed files with 32953 additions and 20092 deletions

View file

@ -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 '@/i.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);
}
};
}

View file

@ -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,
});
}
});
}

View file

@ -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;
}

View file

@ -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(/:[\p{Letter}\p{Number}\p{Mark}_+-]+:/u).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.normalize('NFC'));
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);
});
}
}
}

View file

@ -0,0 +1,937 @@
/*
* 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: 'CsSVILKpX',
label: i18n.ts.themeForDarkMode,
keywords: ['dark', 'theme'],
},
{
id: '8wcoRp76b',
label: i18n.ts.setWallpaper,
keywords: ['wallpaper'],
},
],
label: i18n.ts.theme,
keywords: ['theme'],
path: '/settings/theme',
icon: 'ti ti-palette',
},
{
id: '6fFIRXUww',
children: [
{
id: 'EcwZE7dCl',
label: i18n.ts.notUseSound,
keywords: ['mute'],
},
{
id: '9MxYVIf7k',
label: i18n.ts.useSoundOnlyWhenActive,
keywords: ['active', 'mute'],
},
{
id: '94afQxKat',
label: i18n.ts.masterVolume,
keywords: ['volume', 'master'],
},
],
label: i18n.ts.sounds,
keywords: ['sounds', i18n.ts._settings.soundsBanner],
path: '/settings/sounds',
icon: 'ti ti-music',
},
{
id: '5BjnxMfYV',
children: [
{
id: '75QPEg57v',
children: [
{
id: 'CiHijRkGG',
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', i18n.ts._settings.securityBanner],
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: 'BhAQiHogN',
label: i18n.ts.makeFollowManuallyApprove,
keywords: ['follow', 'lock', i18n.ts.lockedAccountInfo],
},
{
id: '4DeWGsPaD',
label: i18n.ts.autoAcceptFollowed,
keywords: ['follow', 'auto', 'accept'],
},
{
id: 'iaM6zUmO9',
label: i18n.ts.makeReactionsPublic,
keywords: ['reaction', 'public', i18n.ts.makeReactionsPublicDescription],
},
{
id: '5Q6uhghzV',
label: i18n.ts.followingVisibility,
keywords: ['following', 'visibility'],
},
{
id: 'pZ9q65FX5',
label: i18n.ts.followersVisibility,
keywords: ['follower', 'visibility'],
},
{
id: 'DMS4yvAGg',
label: i18n.ts.hideOnlineStatus,
keywords: ['online', 'status', i18n.ts.hideOnlineStatusDescription],
},
{
id: '8rEsGuN8w',
label: i18n.ts.noCrawle,
keywords: ['crawle', 'index', 'search', i18n.ts.noCrawleDescription],
},
{
id: 's7LdSpiLn',
label: i18n.ts.preventAiLearning,
keywords: ['crawle', 'ai', i18n.ts.preventAiLearningDescription],
},
{
id: 'l2Wf1s2ad',
label: i18n.ts.makeExplorable,
keywords: ['explore', i18n.ts.makeExplorableDescription],
},
{
id: 'xEYlOghao',
label: i18n.ts._chat.chatAllowedUsers,
keywords: ['chat'],
},
{
id: 'BnOtlyaAh',
children: [
{
id: 'BzMIVBpL0',
label: i18n.ts._accountSettings.requireSigninToViewContents,
keywords: ['login', 'signin'],
},
{
id: 'jJUqPqBAv',
label: i18n.ts._accountSettings.makeNotesFollowersOnlyBefore,
keywords: ['follower', i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription],
},
{
id: 'ra10txIFV',
label: i18n.ts._accountSettings.makeNotesHiddenBefore,
keywords: ['hidden', i18n.ts._accountSettings.makeNotesHiddenBeforeDescription],
},
],
label: i18n.ts.lockdown,
keywords: ['lockdown'],
},
],
label: i18n.ts.privacy,
keywords: ['privacy', i18n.ts._settings.privacyBanner],
path: '/settings/privacy',
icon: 'ti ti-lock-open',
},
{
id: '3yCAv0IsZ',
children: [
{
id: 'AKvDrxSj5',
children: [
{
id: 'cAszhShB0',
label: i18n.ts.uiLanguage,
keywords: ['language'],
},
{
id: 'apz9AutPm',
label: i18n.ts.overridedDeviceKind,
keywords: ['device', 'type', 'kind', 'smartphone', 'tablet', 'desktop'],
},
{
id: 'nqRVtw1xw',
label: i18n.ts.useBlurEffect,
keywords: ['blur'],
},
{
id: 'EO5WHBeG8',
label: i18n.ts.useBlurEffectForModal,
keywords: ['blur', 'modal'],
},
{
id: 'CWpyT9vLK',
label: i18n.ts.showAvatarDecorations,
keywords: ['avatar', 'icon', 'decoration', 'show'],
},
{
id: '1wwACqQz1',
label: i18n.ts.alwaysConfirmFollow,
keywords: ['follow', 'confirm', 'always'],
},
{
id: '1x3JNXj8N',
label: i18n.ts.highlightSensitiveMedia,
keywords: ['highlight', 'sensitive', 'nsfw', 'image', 'photo', 'picture', 'media', 'thumbnail'],
},
{
id: 'CfAg0Qekq',
label: i18n.ts.confirmWhenRevealingSensitiveMedia,
keywords: ['sensitive', 'nsfw', 'media', 'image', 'photo', 'picture', 'attachment', 'confirm'],
},
{
id: 'aefexW9fD',
label: i18n.ts.enableAdvancedMfm,
keywords: ['mfm', 'enable', 'show', 'advanced'],
},
{
id: 'lu9v5Spqg',
label: i18n.ts.enableInfiniteScroll,
keywords: ['auto', 'load', 'auto', 'more', 'scroll'],
},
{
id: '6kMj4HVOg',
label: i18n.ts.emojiStyle,
keywords: ['emoji', 'style', 'native', 'system', 'fluent', 'twemoji'],
},
{
id: 'DftdlLbNu',
label: i18n.ts.pinnedList,
keywords: ['pinned', 'list'],
},
],
label: i18n.ts.general,
keywords: ['general'],
},
{
id: 'CQldliCSi',
children: [
{
id: 'kMB2hPyq3',
label: i18n.ts.showFixedPostForm,
keywords: ['post', 'form', 'timeline'],
},
{
id: 'jC7LtTnmc',
label: i18n.ts.showFixedPostFormInChannel,
keywords: ['post', 'form', 'timeline', 'channel'],
},
{
id: 'p2wlrnwLo',
label: i18n.ts.collapseRenotes,
keywords: ['renote', i18n.ts.collapseRenotesDescription],
},
{
id: '6SFn3t8VS',
label: i18n.ts.showGapBetweenNotesInTimeline,
keywords: ['note', 'timeline', 'gap'],
},
{
id: 'nygexkaUk',
label: i18n.ts.disableStreamingTimeline,
keywords: ['disable', 'streaming', 'timeline'],
},
{
id: '7vnQgR42v',
label: i18n.ts.showNoteActionsOnlyHover,
keywords: ['hover', 'show', 'footer', 'action'],
},
{
id: 'x5q4XZ7Kv',
label: i18n.ts.showClipButtonInNoteFooter,
keywords: ['footer', 'action', 'clip', 'show'],
},
{
id: 'x9irZWjaF',
label: i18n.ts.showReactionsCount,
keywords: ['reaction', 'count', 'show'],
},
{
id: 'dHPv9mrxi',
label: i18n.ts.confirmOnReact,
keywords: ['reaction', 'confirm'],
},
{
id: 'bj42W4cvN',
label: i18n.ts.loadRawImages,
keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'quality', 'raw', 'attachment'],
},
{
id: 'fzPca1Gk9',
label: i18n.ts.useReactionPickerForContextMenu,
keywords: ['reaction', 'picker', 'contextmenu', 'open'],
},
{
id: 'mNU5IBln7',
label: i18n.ts.reactionsDisplaySize,
keywords: ['reaction', 'size', 'scale', 'display'],
},
{
id: 'kYgorbLUy',
label: i18n.ts.limitWidthOfReaction,
keywords: ['reaction', 'size', 'scale', 'display', 'width', 'limit'],
},
{
id: 'm75VEWI3S',
label: i18n.ts.mediaListWithOneImageAppearance,
keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'list', 'size', 'height'],
},
{
id: 'CA42sC9Mx',
label: i18n.ts.instanceTicker,
keywords: ['ticker', 'information', 'label', 'instance', 'server', 'host', 'federation'],
},
{
id: 'knEhibyFp',
label: i18n.ts.displayOfSensitiveMedia,
keywords: ['attachment', 'image', 'photo', 'picture', 'media', 'thumbnail', 'nsfw', 'sensitive', 'display', 'show', 'hide', 'visibility'],
},
],
label: i18n.ts._settings.timelineAndNote,
keywords: ['timeline', 'note'],
},
{
id: 'yIR4YP0yU',
children: [
{
id: 'cBkUgQNpH',
label: i18n.ts.keepCw,
keywords: ['remember', 'keep', 'note', 'cw'],
},
{
id: 'Bv4YywaKL',
label: i18n.ts.rememberNoteVisibility,
keywords: ['remember', 'keep', 'note', 'visibility'],
},
{
id: 'F3kpUNvSQ',
label: i18n.ts.enableQuickAddMfmFunction,
keywords: ['mfm', 'enable', 'show', 'advanced', 'picker', 'form', 'function', 'fn'],
},
{
id: 'BBxwy4F6E',
label: i18n.ts.defaultNoteVisibility,
keywords: ['default', 'note', 'visibility'],
},
],
label: i18n.ts.postForm,
keywords: ['post', 'form'],
},
{
id: 'e5XnQWk68',
children: [
{
id: 'rOttgccaS',
label: i18n.ts.useGroupedNotifications,
keywords: ['group'],
},
{
id: 'Ek4Cw3VPq',
label: i18n.ts.position,
keywords: ['position'],
},
{
id: 'pZLzt3i0s',
label: i18n.ts.stackAxis,
keywords: ['stack', 'axis', 'direction'],
},
],
label: i18n.ts.notifications,
keywords: ['notification'],
},
{
id: 'c9mbgmHQp',
label: i18n.ts.dataSaver,
keywords: ['datasaver'],
},
{
id: '5h8vhCX1S',
children: [
{
id: 'bDv03znUy',
label: i18n.ts.squareAvatars,
keywords: ['avatar', 'icon', 'square'],
},
{
id: 'nkR2LWURW',
label: i18n.ts.seasonalScreenEffect,
keywords: ['effect', 'show'],
},
{
id: 'sCscGhMmH',
label: i18n.ts.openImageInNewTab,
keywords: ['image', 'photo', 'picture', 'media', 'thumbnail', 'new', 'tab'],
},
{
id: '4yCgcFElF',
label: i18n.ts.withRepliesByDefaultForNewlyFollowed,
keywords: ['follow', 'replies'],
},
{
id: '5iMpm5rES',
label: i18n.ts.whenServerDisconnected,
keywords: ['server', 'disconnect', 'reconnect', 'reload', 'streaming'],
},
{
id: 'dlQjnWBVU',
label: i18n.ts.numberOfPageCache,
keywords: ['cache', 'page'],
},
{
id: 'qY5xTzl35',
label: i18n.ts.forceShowAds,
keywords: ['ad', 'show'],
},
{
id: '2VSnj81vC',
label: i18n.ts.hemisphere,
keywords: [],
},
{
id: 'vuG3aG3IE',
label: i18n.ts.additionalEmojiDictionary,
keywords: ['emoji', 'dictionary', 'additional', 'extra'],
},
],
label: i18n.ts.other,
keywords: ['other'],
},
],
label: i18n.ts.preferences,
keywords: ['general', 'preferences', i18n.ts._settings.preferencesBanner],
path: '/settings/preferences',
icon: 'ti ti-adjustments',
},
{
id: 'mwkwtw83Y',
label: i18n.ts.plugins,
keywords: ['plugin', 'addon', 'extension', i18n.ts._settings.pluginBanner],
path: '/settings/plugin',
icon: 'ti ti-plug',
},
{
id: 'F1uK9ssiY',
children: [
{
id: 'E0ndmaP6Q',
label: i18n.ts._role.policies,
keywords: ['account', 'info'],
},
{
id: 'r5SjfwZJc',
label: i18n.ts.rolesAssignedToMe,
keywords: ['roles'],
},
{
id: 'cm7LrjgaW',
label: i18n.ts.accountMigration,
keywords: ['account', 'move', 'migration'],
},
{
id: 'ozfqNviP3',
label: i18n.ts.closeAccount,
keywords: ['account', 'close', 'delete', i18n.ts._accountDelete.requestAccountDelete],
},
{
id: 'tpywgkpxy',
label: i18n.ts.experimentalFeatures,
keywords: ['experimental', 'feature', 'flags'],
},
{
id: 'zWbGKohZ2',
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: 'lO3uFTkPN',
children: [
{
id: '5JKaXRqyt',
label: i18n.ts.showMutedWord,
keywords: ['show'],
},
],
label: i18n.ts.wordMute,
keywords: ['note', 'word', 'soft', 'mute', 'hide'],
},
{
id: 'fMkjL3dK4',
label: i18n.ts.hardWordMute,
keywords: ['note', 'word', 'hard', 'mute', 'hide'],
},
{
id: 'cimSzQXN0',
label: i18n.ts.instanceMute,
keywords: ['note', 'server', 'instance', 'host', 'federation', 'mute', 'hide'],
},
{
id: 'gq8rPy3Du',
label: `${i18n.ts.mutedUsers} (${ i18n.ts.renote })`,
keywords: ['renote', 'mute', 'hide', 'user'],
},
{
id: 'mh2r7EUbF',
label: i18n.ts.mutedUsers,
keywords: ['note', 'mute', 'hide', 'user'],
},
{
id: 'AUS1OgHrn',
label: i18n.ts.blockedUsers,
keywords: ['block', 'user'],
},
],
label: i18n.ts.muteAndBlock,
keywords: ['mute', 'block', i18n.ts._settings.muteAndBlockBanner],
path: '/settings/mute-block',
icon: 'ti ti-ban',
},
{
id: 'yR1OSyLiT',
children: [
{
id: 'yMJzyzOUk',
label: i18n.ts._emojiPalette.enableSyncBetweenDevicesForPalettes,
keywords: ['sync', 'palettes', 'devices'],
},
{
id: 'wCE09vgZr',
label: i18n.ts._emojiPalette.paletteForMain,
keywords: ['main', 'palette'],
},
{
id: 'uCzRPrSNx',
label: i18n.ts._emojiPalette.paletteForReaction,
keywords: ['reaction', 'palette'],
},
{
id: 'hgQr28WUk',
children: [
{
id: 'fY04NIHSQ',
label: i18n.ts.size,
keywords: ['emoji', 'picker', 'scale', 'size'],
},
{
id: '3j7vlaL7t',
label: i18n.ts.numberOfColumn,
keywords: ['emoji', 'picker', 'width', 'column', 'size'],
},
{
id: 'zPX8z1Bcy',
label: i18n.ts.height,
keywords: ['emoji', 'picker', 'height', 'size'],
},
{
id: '2CSkZa4tl',
label: i18n.ts.style,
keywords: ['emoji', 'picker', 'style'],
},
],
label: i18n.ts.emojiPickerDisplay,
keywords: ['emoji', 'picker', 'display'],
},
],
label: i18n.ts.emojiPalette,
keywords: ['emoji', 'palette'],
path: '/settings/emoji-palette',
icon: 'ti ti-mood-happy',
},
{
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: 'cN3dsGNxu',
label: i18n.ts.usageAmount,
keywords: ['capacity', 'usage'],
},
{
id: 'rOAOU2P6C',
label: i18n.ts.statistics,
keywords: ['statistics', 'usage'],
},
{
id: 'uXGlQXATx',
label: i18n.ts.uploadFolder,
keywords: ['default', 'upload', 'folder'],
},
{
id: 'goQdtf3dD',
label: i18n.ts.keepOriginalUploading,
keywords: ['keep', 'original', 'raw', 'upload', i18n.ts.keepOriginalUploadingDescription],
},
{
id: '83xRo0XJl',
label: i18n.ts.keepOriginalFilename,
keywords: ['keep', 'original', 'filename', i18n.ts.keepOriginalFilenameDescription],
},
{
id: 'wf77yRQQq',
label: i18n.ts.alwaysMarkSensitive,
keywords: ['always', 'default', 'mark', 'nsfw', 'sensitive', 'media', 'file'],
},
{
id: '3pxwNB8e4',
label: i18n.ts.enableAutoSensitive,
keywords: ['auto', 'nsfw', 'sensitive', 'media', 'file', i18n.ts.enableAutoSensitiveDescription],
},
],
label: i18n.ts.drive,
keywords: ['drive', i18n.ts._settings.driveBanner],
path: '/settings/drive',
icon: 'ti ti-cloud',
},
{
id: 'FfZdOs8y',
children: [
{
id: 'B1ZU6Ur54',
label: i18n.ts._deck.enableSyncBetweenDevicesForProfiles,
keywords: ['sync', 'profiles', 'devices'],
},
{
id: 'iEF0gqNAo',
label: i18n.ts._deck.useSimpleUiForNonRootPages,
keywords: ['ui', 'root', 'page'],
},
{
id: 'BNdSeWxZn',
label: i18n.ts.defaultNavigationBehaviour,
keywords: ['default', 'navigation', 'behaviour', 'window'],
},
{
id: 'zT9pGm8DF',
label: i18n.ts._deck.alwaysShowMainColumn,
keywords: ['always', 'show', 'main', 'column'],
},
{
id: '5dk2xv1vc',
label: i18n.ts._deck.columnAlign,
keywords: ['column', 'align'],
},
],
label: i18n.ts.deck,
keywords: ['deck', 'ui'],
path: '/settings/deck',
icon: 'ti ti-columns',
},
{
id: 'BlJ2rsw9h',
children: [
{
id: '9bLU1nIjt',
label: i18n.ts._settings.api,
keywords: ['api', 'app', 'token', 'accessToken'],
},
{
id: '5VSGOVYR0',
label: i18n.ts._settings.webhook,
keywords: ['webhook'],
},
],
label: i18n.ts._settings.serviceConnection,
keywords: ['app', 'service', 'connect', 'webhook', 'api', 'token', i18n.ts._settings.serviceConnectionBanner],
path: '/settings/connect',
icon: 'ti ti-link',
},
{
id: 'gtaOSdIJB',
label: i18n.ts.avatarDecorations,
keywords: ['avatar', 'icon', 'decoration'],
path: '/settings/avatar-decoration',
icon: 'ti ti-sparkles',
},
{
id: 'zK6posor9',
label: i18n.ts.accounts,
keywords: ['accounts'],
path: '/settings/accounts',
icon: 'ti ti-users',
},
{
id: '330Q4mf8E',
children: [
{
id: 'eGSjUDIKu',
label: i18n.ts._exportOrImport.allNotes,
keywords: ['notes'],
},
{
id: 'iMDgUVgRu',
label: i18n.ts._exportOrImport.favoritedNotes,
keywords: ['favorite', 'notes'],
},
{
id: '3y6KgkVbT',
label: i18n.ts._exportOrImport.clips,
keywords: ['clip', 'notes'],
},
{
id: 'cKiHkj8HE',
label: i18n.ts._exportOrImport.followingList,
keywords: ['following', 'users'],
},
{
id: '3zzmQXn0t',
label: i18n.ts._exportOrImport.userLists,
keywords: ['user', 'lists'],
},
{
id: '3ZGXcEqWZ',
label: i18n.ts._exportOrImport.muteList,
keywords: ['mute', 'users'],
},
{
id: '84oL7B1Dr',
label: i18n.ts._exportOrImport.blockingList,
keywords: ['block', 'users'],
},
{
id: 'ckqi48Kbl',
label: i18n.ts.antennas,
keywords: ['antennas'],
},
],
label: i18n.ts._settings.accountData,
keywords: ['import', 'export', 'data', 'archive', i18n.ts._settings.accountDataBanner],
path: '/settings/account-data',
icon: 'ti ti-package',
},
{
id: 'f08Mi1Uwn',
children: [
{
id: 'C5dRH2Ypy',
label: i18n.ts.reduceUiAnimation,
keywords: ['animation', 'motion', 'reduce'],
},
{
id: '5mZxz2cru',
label: i18n.ts.disableShowingAnimatedImages,
keywords: ['disable', 'animation', 'image', 'photo', 'picture', 'media', 'thumbnail', 'gif'],
},
{
id: 'c0Iy5hL5o',
label: i18n.ts.enableAnimatedMfm,
keywords: ['mfm', 'enable', 'show', 'animated'],
},
{
id: '4HYFjs2Nv',
label: i18n.ts.enableHorizontalSwipe,
keywords: ['swipe', 'horizontal', 'tab'],
},
{
id: 'kYVJ3SVNq',
label: i18n.ts.keepScreenOn,
keywords: ['keep', 'screen', 'display', 'on'],
},
{
id: 'w4Bv0meAt',
label: i18n.ts.useNativeUIForVideoAudioPlayer,
keywords: ['native', 'system', 'video', 'audio', 'player', 'media'],
},
{
id: 'b1GYEEJeh',
label: i18n.ts._settings.makeEveryTextElementsSelectable,
keywords: ['text', 'selectable'],
},
{
id: 'vVLxwINTJ',
label: i18n.ts.menuStyle,
keywords: ['menu', 'style', 'popup', 'drawer'],
},
{
id: '14cMhMLHL',
label: i18n.ts._contextMenu.title,
keywords: ['contextmenu', 'system', 'native'],
},
{
id: 'oSo4LXMX9',
label: i18n.ts.fontSize,
keywords: ['font', 'size'],
},
{
id: '7LQSAThST',
label: i18n.ts.useSystemFont,
keywords: ['font', 'system', 'native'],
},
],
label: i18n.ts.accessibility,
keywords: ['accessibility', i18n.ts._settings.accessibilityBanner],
path: '/settings/accessibility',
icon: 'ti ti-accessible',
},
] as const;
export type SearchIndex = typeof searchIndexes;

View file

@ -0,0 +1,89 @@
/*
* SPDX-FileCopyrightText: dakkar and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ref, Ref, computed, ComputedRef } from 'vue';
import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';
import { MenuItem } from '@/types/menu.js';
/*
this script should eventually contain all Sharkey-specific bits of
boosting and quoting that we would otherwise have to replicate in
`{M,S}kNote{,Detailed,Sub}.vue`
*/
export type Visibility = 'public' | 'home' | 'followers' | 'specified';
export function smallerVisibility(a: Visibility | string, b: Visibility | string): 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 visibilityIsAtLeast(a: Visibility | string, b: Visibility | string): boolean {
return smallerVisibility(a, b) === b;
}
export function boostMenuItems(appearNote: Ref<Misskey.entities.Note>, renote: (v: Visibility, l: boolean) => void): MenuItem[] {
const localOnly = ref(defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly);
const effectiveVisibility = (
appearNote.value.channel?.isSensitive
? smallerVisibility(appearNote.value.visibility, 'home')
: appearNote.value.visibility
);
const menuItems: MenuItem[] = [];
if (visibilityIsAtLeast(effectiveVisibility, 'public')) {
menuItems.push({
type: 'button',
icon: 'ph-globe-hemisphere-west ph-bold ph-lg',
text: i18n.ts._visibility['public'],
action: () => {
renote('public', localOnly.value);
},
} as MenuItem);
}
if (visibilityIsAtLeast(effectiveVisibility, 'home')) {
menuItems.push({
type: 'button',
icon: 'ph-house ph-bold ph-lg',
text: i18n.ts._visibility['home'],
action: () => {
renote('home', localOnly.value);
},
} as MenuItem);
}
if (visibilityIsAtLeast(effectiveVisibility, 'followers')) {
menuItems.push({
type: 'button',
icon: 'ph-lock ph-bold ph-lg',
text: i18n.ts._visibility['followers'],
action: () => {
renote('followers', localOnly.value);
},
} as MenuItem);
}
return [
...menuItems,
{
type: 'switch',
icon: 'ph-planet ph-bold ph-lg',
text: i18n.ts._timelines.local,
ref: localOnly,
} as MenuItem,
];
}
export function computeRenoteTooltip(renoted: Ref<boolean>): ComputedRef<string> {
return computed(() => {
if (renoted.value) return i18n.ts.unrenote;
if (defaultStore.state.showVisibilitySelectorOnBoost) return i18n.ts.renote;
return i18n.ts.renoteShift;
});
}

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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;

View file

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as mfm from '@transfem-org/sfm-js';
export function checkAnimationFromMfm(nodes: mfm.MfmNode[]): boolean {
const animatedNodes = mfm.extract(nodes, (node) => {
if (node.type === 'fn') {
if (node.props.name === 'tada' ||
node.props.name === 'jelly' ||
node.props.name === 'twitch' ||
node.props.name === 'shake' ||
node.props.name === 'spin' ||
node.props.name === 'jump' ||
node.props.name === 'bounce' ||
node.props.name === 'rainbow' ||
node.props.name === 'sparkle' ||
node.props.name === 'fade' ||
node.props.name === 'followmouse') {
return true;
} else {
return false;
}
} else {
return false;
}
});
if (animatedNodes.length > 0) {
return true;
} else {
return false;
}
}

View file

@ -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 '@/i.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'
);

View file

@ -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)));
}

View file

@ -0,0 +1,65 @@
/*
* 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 = getNoteText(note);
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;
}
function getNoteText(note: Note): string {
const textParts: string[] = [];
if (note.cw) textParts.push(note.cw);
if (note.text) textParts.push(note.text);
if (note.files) {
for (const file of note.files) {
if (file.comment) textParts.push(file.comment);
}
}
if (note.poll) {
for (const choice of note.poll.choices) {
if (choice.text) textParts.push(choice.text);
}
}
return textParts.join('\n').trim();
}

View file

@ -0,0 +1,347 @@
/*
* SPDX-FileCopyrightText: marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
/* eslint-disable */
const ChiptuneAudioContext = window.AudioContext || window.webkitAudioContext;
let libopenmpt
let libopenmptLoadPromise
type ChiptuneJsConfig = {
repeatCount: number | null;
context: AudioContext | null;
};
export function ChiptuneJsConfig (repeatCount?: number, context?: AudioContext) {
this.repeatCount = repeatCount;
this.context = context;
}
ChiptuneJsConfig.prototype.constructor = ChiptuneJsConfig;
export function ChiptuneJsPlayer (config: ChiptuneJsConfig) {
this.config = config;
this.audioContext = config.context || new ChiptuneAudioContext();
this.context = this.audioContext.createGain();
this.currentPlayingNode = null;
this.handlers = [];
this.touchLocked = true;
this.volume = 1;
}
ChiptuneJsPlayer.prototype.initialize = function() {
if (libopenmptLoadPromise) return libopenmptLoadPromise;
if (libopenmpt) return Promise.resolve();
libopenmptLoadPromise = new Promise<void>(async (resolve, reject) => {
try {
const { Module } = await import('./libopenmpt/libopenmpt.js');
await new Promise((resolve) => {
Module['onRuntimeInitialized'] = resolve;
})
libopenmpt = Module;
resolve()
} catch (e) {
reject(e)
} finally {
libopenmptLoadPromise = undefined;
}
})
return libopenmptLoadPromise;
}
ChiptuneJsPlayer.prototype.constructor = ChiptuneJsPlayer;
ChiptuneJsPlayer.prototype.fireEvent = function (eventName: string, response) {
const handlers = this.handlers;
if (handlers.length > 0) {
for(const handler of handlers) {
if (handler.eventName === eventName) {
handler.handler(response);
}
}
}
};
ChiptuneJsPlayer.prototype.addHandler = function (eventName: string, handler: Function) {
this.handlers.push({ eventName, handler });
};
ChiptuneJsPlayer.prototype.onEnded = function (handler: Function) {
this.addHandler('onEnded', handler);
};
ChiptuneJsPlayer.prototype.onError = function (handler: Function) {
this.addHandler('onError', handler);
};
ChiptuneJsPlayer.prototype.duration = function () {
return libopenmpt._openmpt_module_get_duration_seconds(this.currentPlayingNode.modulePtr);
};
ChiptuneJsPlayer.prototype.position = function () {
return libopenmpt._openmpt_module_get_position_seconds(this.currentPlayingNode.modulePtr);
};
ChiptuneJsPlayer.prototype.seek = function (position: number) {
if (this.currentPlayingNode) {
libopenmpt._openmpt_module_set_position_seconds(this.currentPlayingNode.modulePtr, position);
}
};
ChiptuneJsPlayer.prototype.metadata = function () {
const data = {};
const keys = libopenmpt.UTF8ToString(libopenmpt._openmpt_module_get_metadata_keys(this.currentPlayingNode.modulePtr)).split(';');
let keyNameBuffer = 0;
for (const key of keys) {
keyNameBuffer = libopenmpt._malloc(key.length + 1);
libopenmpt.writeAsciiToMemory(key, keyNameBuffer);
data[key] = libopenmpt.UTF8ToString(libopenmpt._openmpt_module_get_metadata(this.currentPlayingNode.modulePtr, keyNameBuffer));
libopenmpt._free(keyNameBuffer);
}
return data;
};
ChiptuneJsPlayer.prototype.unlock = function () {
const context = this.audioContext;
const buffer = context.createBuffer(1, 1, 22050);
const unlockSource = context.createBufferSource();
unlockSource.buffer = buffer;
unlockSource.connect(this.context);
this.context.connect(context.destination);
unlockSource.start(0);
this.touchLocked = false;
};
ChiptuneJsPlayer.prototype.load = function (input) {
return this.initialize().then(() => new Promise((resolve, reject) => {
if(this.touchLocked) {
this.unlock();
}
const player = this;
if (input instanceof File) {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.readAsArrayBuffer(input);
} else {
window.fetch(input).then((response) => {
response.arrayBuffer().then((arrayBuffer) => {
resolve(arrayBuffer);
}).catch((error) => {
reject(error);
});
}).catch((error) => {
reject(error);
});
}
}));
};
ChiptuneJsPlayer.prototype.play = function (buffer: ArrayBuffer) {
this.unlock();
this.stop();
const processNode = this.createLibopenmptNode(buffer, this.buffer);
if (processNode === null) {
return;
}
libopenmpt._openmpt_module_set_repeat_count(processNode.modulePtr, this.config.repeatCount || 0);
this.currentPlayingNode = processNode;
processNode.connect(this.context);
this.context.connect(this.audioContext.destination);
};
ChiptuneJsPlayer.prototype.stop = function () {
if (this.currentPlayingNode != null) {
this.currentPlayingNode.disconnect();
this.currentPlayingNode.cleanup();
this.currentPlayingNode = null;
}
};
ChiptuneJsPlayer.prototype.togglePause = function () {
if (this.currentPlayingNode != null) {
this.currentPlayingNode.togglePause();
}
};
ChiptuneJsPlayer.prototype.getPattern = function () {
if (this.currentPlayingNode && this.currentPlayingNode.modulePtr) {
return libopenmpt._openmpt_module_get_current_pattern(this.currentPlayingNode.modulePtr);
}
return 0;
};
ChiptuneJsPlayer.prototype.getRow = function () {
if (this.currentPlayingNode && this.currentPlayingNode.modulePtr) {
return libopenmpt._openmpt_module_get_current_row(this.currentPlayingNode.modulePtr);
}
return 0;
};
ChiptuneJsPlayer.prototype.getNumPatterns = function () {
if (this.currentPlayingNode && this.currentPlayingNode.modulePtr) {
return libopenmpt._openmpt_module_get_num_patterns(this.currentPlayingNode.modulePtr);
}
return 0;
};
ChiptuneJsPlayer.prototype.getCurrentSpeed = function () {
if (this.currentPlayingNode && this.currentPlayingNode.modulePtr) {
return libopenmpt._openmpt_module_get_current_speed(this.currentPlayingNode.modulePtr);
}
return 0;
};
ChiptuneJsPlayer.prototype.getCurrentTempo = function () {
if (this.currentPlayingNode && this.currentPlayingNode.modulePtr) {
return libopenmpt._openmpt_module_get_current_tempo(this.currentPlayingNode.modulePtr);
}
return 0;
};
ChiptuneJsPlayer.prototype.getPatternNumRows = function (pattern: number) {
if (this.currentPlayingNode && this.currentPlayingNode.modulePtr) {
return libopenmpt._openmpt_module_get_pattern_num_rows(this.currentPlayingNode.modulePtr, pattern);
}
return 0;
};
ChiptuneJsPlayer.prototype.getPatternRowChannel = function (pattern: number, row: number, channel: number) {
if (this.currentPlayingNode && this.currentPlayingNode.modulePtr) {
return libopenmpt.UTF8ToString(libopenmpt._openmpt_module_format_pattern_row_channel(this.currentPlayingNode.modulePtr, pattern, row, channel, 0, true));
}
return '';
};
ChiptuneJsPlayer.prototype.getCtls = function () {
if (this.currentPlayingNode && this.currentPlayingNode.modulePtr) {
return libopenmpt._openmpt_module_get_ctls(this.currentPlayingNode.modulePtr);
}
return 0;
};
ChiptuneJsPlayer.prototype.version = function () {
if (this.currentPlayingNode && this.currentPlayingNode.modulePtr) {
return libopenmpt._openmpt_get_library_version();
}
return 0;
};
ChiptuneJsPlayer.prototype.createLibopenmptNode = function (buffer, config: object) {
const maxFramesPerChunk = 4096;
const processNode = this.audioContext.createScriptProcessor(2048, 0, 2);
processNode.config = config;
processNode.player = this;
const byteArray = new Int8Array(buffer);
const ptrToFile = libopenmpt._malloc(byteArray.byteLength);
libopenmpt.HEAPU8.set(byteArray, ptrToFile);
processNode.modulePtr = libopenmpt._openmpt_module_create_from_memory(ptrToFile, byteArray.byteLength, 0, 0, 0);
processNode.nbChannels = libopenmpt._openmpt_module_get_num_channels(processNode.modulePtr);
processNode.patternIndex = -1;
processNode.paused = false;
processNode.leftBufferPtr = libopenmpt._malloc(4 * maxFramesPerChunk);
processNode.rightBufferPtr = libopenmpt._malloc(4 * maxFramesPerChunk);
processNode.perf = { 'current': 0, 'max': 0 };
processNode.cleanup = function () {
if (this.modulePtr !== 0) {
libopenmpt._openmpt_module_destroy(this.modulePtr);
this.modulePtr = 0;
}
if (this.leftBufferPtr !== 0) {
libopenmpt._free(this.leftBufferPtr);
this.leftBufferPtr = 0;
}
if (this.rightBufferPtr !== 0) {
libopenmpt._free(this.rightBufferPtr);
this.rightBufferPtr = 0;
}
};
processNode.stop = function () {
this.disconnect();
this.cleanup();
};
processNode.pause = function () {
this.paused = true;
};
processNode.unpause = function () {
this.paused = false;
};
processNode.togglePause = function () {
this.paused = !this.paused;
};
processNode.getProcessTime = function() {
const max = this.perf.max;
this.perf.max = 0;
return { 'current': this.perf.current, 'max': max };
};
processNode.onaudioprocess = function (e) {
let startTimeP1 = performance.now();
const outputL = e.outputBuffer.getChannelData(0);
const outputR = e.outputBuffer.getChannelData(1);
let framesToRender = outputL.length;
if (this.ModulePtr === 0) {
for (let i = 0; i < framesToRender; ++i) {
outputL[i] = 0;
outputR[i] = 0;
}
this.disconnect();
this.cleanup();
return;
}
if (this.paused) {
for (let i = 0; i < framesToRender; ++i) {
outputL[i] = 0;
outputR[i] = 0;
}
return;
}
let framesRendered = 0;
let ended = false;
let error = false;
const currentPattern = libopenmpt._openmpt_module_get_current_pattern(this.modulePtr);
const currentRow = libopenmpt._openmpt_module_get_current_row(this.modulePtr);
startTimeP1 = startTimeP1 - performance.now();
if (currentPattern !== this.patternIndex) {
processNode.player.fireEvent('onPatternChange');
}
processNode.player.fireEvent('onRowChange', { index: currentRow });
const startTimeP2 = performance.now();
while (framesToRender > 0) {
const framesPerChunk = Math.min(framesToRender, maxFramesPerChunk);
const actualFramesPerChunk = libopenmpt._openmpt_module_read_float_stereo(this.modulePtr, this.context.sampleRate, framesPerChunk, this.leftBufferPtr, this.rightBufferPtr);
if (actualFramesPerChunk === 0) {
ended = true;
// modulePtr will be 0 on openmpt: error: openmpt_module_read_float_stereo: ERROR: module * not valid or other openmpt error
error = !this.modulePtr;
}
const rawAudioLeft = libopenmpt.HEAPF32.subarray(this.leftBufferPtr / 4, this.leftBufferPtr / 4 + actualFramesPerChunk);
const rawAudioRight = libopenmpt.HEAPF32.subarray(this.rightBufferPtr / 4, this.rightBufferPtr / 4 + actualFramesPerChunk);
for (let i = 0; i < actualFramesPerChunk; ++i) {
outputL[framesRendered + i] = rawAudioLeft[i];
outputR[framesRendered + i] = rawAudioRight[i];
}
for (let i = actualFramesPerChunk; i < framesPerChunk; ++i) {
outputL[framesRendered + i] = 0;
outputR[framesRendered + i] = 0;
}
framesToRender -= framesPerChunk;
framesRendered += framesPerChunk;
}
if (ended) {
this.disconnect();
this.cleanup();
error ? processNode.player.fireEvent('onError', { type: 'openmpt' }) : processNode.player.fireEvent('onEnded');
}
this.perf.current = performance.now() - startTimeP2 + startTimeP1;
if (this.perf.current > this.perf.max) this.perf.max = this.perf.current;
};
return processNode;
};

View file

@ -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();
}

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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})`;
};

View file

@ -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);
}

View file

@ -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;
};

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
/**
* Clipboardに値をコピー(TODO: 文字列以外も対応)
*/
export function copyToClipboard(input: string | null) {
if (input) {
navigator.clipboard.writeText(input);
os.toast(i18n.ts.copiedToClipboard);
}
};

View file

@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
type JsonLike = string | number | boolean | null | undefined | JsonLike[] | { [key: string]: JsonLike } | Map<string, JsonLike>;
export function deepEqual(a: JsonLike, b: JsonLike): boolean {
if (a === b) return true;
if (typeof a !== typeof b) return false;
if (a === null) return b === null;
if (a === undefined) return b === undefined;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!deepEqual(a[i], b[i])) return false;
}
return true;
} else if (a instanceof Map && b instanceof Map) {
if (a.size !== b.size) return false;
for (const [k, v] of a) {
if (!deepEqual(v, b.get(k))) return false;
}
return true;
} else if (((typeof a) === 'object') && ((typeof b) === 'object')) {
const aks = Object.keys(a);
const bks = Object.keys(b as { [key: string]: JsonLike });
if (aks.length !== bks.length) return false;
for (let i = 0; i < aks.length; i++) {
const k = aks[i];
if (!deepEqual(a[k], (b as { [key: string]: JsonLike })[k])) return false;
}
return true;
}
return false;
}

View file

@ -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;
}

View file

@ -0,0 +1,68 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAsyncComponent, ref, watch } from 'vue';
import type { Ref } from 'vue';
import { popup } from '@/os.js';
import { prefer } from '@/preferences.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 = ref<string[]>([]);
watch([prefer.r.emojiPaletteForMain, prefer.r.emojiPalettes], () => {
emojisRef.value = prefer.s.emojiPaletteForMain == null ? prefer.s.emojiPalettes[0].emojis : prefer.s.emojiPalettes.find(palette => palette.id === prefer.s.emojiPaletteForMain)?.emojis ?? [];
}, {
immediate: true,
});
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();

View file

@ -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 '@transfem-org/sfm-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;
}

View file

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as mfm from '@transfem-org/sfm-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[]);
}

View file

@ -0,0 +1,156 @@
/*
* SPDX-FileCopyrightText: leah and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import tinycolor from 'tinycolor2';
class FavIconDot {
private readonly canvas: HTMLCanvasElement;
private src: string | null = null;
private ctx: CanvasRenderingContext2D | null = null;
private faviconImage: HTMLImageElement | null = null;
private faviconEL: HTMLLinkElement | undefined;
private hasLoaded: Promise<void> | undefined;
constructor() {
this.canvas = document.createElement('canvas');
}
/**
* Must be called before calling any other functions
*/
public async setup() {
const element: HTMLLinkElement = await this.getOrMakeFaviconElement();
this.faviconEL = element;
this.src = this.faviconEL.getAttribute('href');
this.ctx = this.canvas.getContext('2d');
this.faviconImage = document.createElement('img');
this.faviconImage.crossOrigin = 'anonymous';
this.hasLoaded = new Promise((resolve, reject) => {
(this.faviconImage as HTMLImageElement).addEventListener('load', () => {
this.canvas.width = (this.faviconImage as HTMLImageElement).width;
this.canvas.height = (this.faviconImage as HTMLImageElement).height;
resolve();
});
(this.faviconImage as HTMLImageElement).addEventListener('error', () => {
reject('Failed to create favicon img element');
});
});
this.faviconImage.src = this.faviconEL.href;
}
private async getOrMakeFaviconElement(): Promise<HTMLLinkElement> {
return new Promise((resolve, reject) => {
const favicon = (document.querySelector('link[rel=icon]') ?? this.createFaviconElem()) as HTMLLinkElement;
favicon.addEventListener('load', () => {
resolve(favicon);
});
favicon.onerror = () => {
reject('Failed to load favicon');
};
resolve(favicon);
});
}
private createFaviconElem() {
const newLink = document.createElement('link');
newLink.setAttribute('rel', 'icon');
newLink.setAttribute('href', '/favicon.ico');
newLink.setAttribute('type', 'image/x-icon');
document.head.appendChild(newLink);
return newLink;
}
private drawIcon() {
if (!this.ctx || !this.faviconImage) return;
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.drawImage(this.faviconImage, 0, 0, this.faviconImage.width, this.faviconImage.height);
}
private drawDot() {
if (!this.ctx || !this.faviconImage) return;
this.ctx.beginPath();
const radius = Math.min(this.faviconImage.width, this.faviconImage.height) * 0.2;
this.ctx.arc(this.faviconImage.width - radius, radius, radius, 0, 2 * Math.PI);
const computedStyle = getComputedStyle(document.documentElement);
this.ctx.fillStyle = tinycolor(computedStyle.getPropertyValue('--MI_THEME-navIndicator')).toHexString();
this.ctx.strokeStyle = 'white';
this.ctx.fill();
this.ctx.stroke();
}
private setFavicon() {
if (this.faviconEL) {
try {
URL.revokeObjectURL(this.faviconEL.href);
} catch {
// the href was probably not an object URL
}
this.canvas.toBlob((blob) => {
const url = URL.createObjectURL(blob);
this.faviconEL.href = url;
});
}
}
public async setVisible(isVisible: boolean) {
// Wait for it to have loaded the icon
await this.hasLoaded;
this.drawIcon();
if (isVisible) this.drawDot();
this.setFavicon();
}
public async worksOnInstance() {
try {
await this.setVisible(true);
await new Promise((resolve) => setTimeout(resolve, 1000));
await this.setVisible(false);
} catch (error) {
console.error('error setting notification dot', error);
return false;
}
return true;
}
}
let icon: FavIconDot | undefined = undefined;
export async function setFavIconDot(visible: boolean) {
const setIconVisibility = async () => {
if (!icon) {
icon = new FavIconDot();
await icon.setup();
}
try {
(icon as FavIconDot).setVisible(visible);
} catch (error) {
console.error('error setting notification dot', error);
}
};
// If document is already loaded, set visibility immediately
if (document.readyState === 'complete') {
await setIconVisibility();
} else {
// Otherwise, set visibility when window loads
window.addEventListener('load', setIconVisibility);
}
}
export async function worksOnInstance() {
if (!icon) {
icon = new FavIconDot();
await icon.setup();
}
return await icon.worksOnInstance();
}

View file

@ -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;
}

View file

@ -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 !== window.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 !== window.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);
},
};
}
}

View file

@ -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) ?? window.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 (window.document.activeElement !== element) {
element.focus({ preventScroll: true });
}
};

View file

@ -0,0 +1,181 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { computed, Ref, WritableComputedRef } from 'vue';
import { defaultStore } from '@/store.js';
import { deepMerge } from '@/scripts/merge.js';
import { PageHeaderItem } from '@/types/page-header.js';
import { i18n } from '@/i18n.js';
import { popupMenu } from '@/os.js';
import { MenuItem } from '@/types/menu.js';
export const followingTab = 'following' as const;
export const mutualsTab = 'mutuals' as const;
export const followersTab = 'followers' as const;
export const followingFeedTabs = [followingTab, mutualsTab, followersTab] as const;
export type FollowingFeedTab = typeof followingFeedTabs[number];
export function followingTabName(tab: FollowingFeedTab): string;
export function followingTabName(tab: FollowingFeedTab | null | undefined): null;
export function followingTabName(tab: FollowingFeedTab | null | undefined): string | null {
if (tab === followingTab) return i18n.ts.following;
if (tab === followersTab) return i18n.ts.followers;
if (tab === mutualsTab) return i18n.ts.mutuals;
return null;
}
export function followingTabIcon(tab: FollowingFeedTab | null | undefined): string {
if (tab === followersTab) return 'ph-user ph-bold ph-lg';
if (tab === mutualsTab) return 'ph-user-switch ph-bold ph-lg';
return 'ph-user-check ph-bold ph-lg';
}
export type FollowingFeedModel = {
[Key in keyof FollowingFeedState]: WritableComputedRef<FollowingFeedState[Key]>;
}
export interface FollowingFeedState {
withNonPublic: boolean,
withQuotes: boolean,
withBots: boolean,
withReplies: boolean,
onlyFiles: boolean,
userList: FollowingFeedTab,
remoteWarningDismissed: boolean,
}
export const defaultFollowingFeedState: FollowingFeedState = {
withNonPublic: false,
withQuotes: false,
withBots: true,
withReplies: false,
onlyFiles: false,
userList: followingTab,
remoteWarningDismissed: false,
};
interface StorageInterface<T extends Partial<FollowingFeedState> = Partial<FollowingFeedState>> {
readonly state: Partial<T>;
readonly reactiveState: Ref<Partial<T>>;
save(updated: T): void;
}
export function createHeaderItem(storage?: Ref<StorageInterface>): PageHeaderItem {
const menu = createOptionsMenu(storage);
return {
icon: 'ti ti-dots',
text: i18n.ts.options,
handler: ev => popupMenu(menu, ev.currentTarget ?? ev.target),
};
}
export function createOptionsMenu(storage?: Ref<StorageInterface>): MenuItem[] {
const {
userList,
withNonPublic,
withQuotes,
withBots,
withReplies,
onlyFiles,
} = createModel(storage);
return [
{
type: 'switch',
text: i18n.ts.showNonPublicNotes,
ref: withNonPublic,
disabled: computed(() => userList.value === followersTab),
},
{
type: 'switch',
text: i18n.ts.showQuotes,
ref: withQuotes,
},
{
type: 'switch',
text: i18n.ts.showBots,
ref: withBots,
},
{
type: 'switch',
text: i18n.ts.showReplies,
ref: withReplies,
disabled: onlyFiles,
},
{
type: 'divider',
},
{
type: 'switch',
text: i18n.ts.fileAttachedOnly,
ref: onlyFiles,
disabled: withReplies,
},
];
}
export function createModel(storage?: Ref<StorageInterface>): FollowingFeedModel {
// eslint-disable-next-line no-param-reassign
storage ??= createDefaultStorage();
// Based on timeline.saveTlFilter()
const saveFollowingFilter = <K extends keyof FollowingFeedState>(key: K, value: FollowingFeedState[K]) => {
const state = deepMerge(storage.value.state, defaultFollowingFeedState);
const out = deepMerge({ [key]: value }, state);
storage.value.save(out);
};
const userList: WritableComputedRef<FollowingFeedTab> = computed({
get: () => storage.value.reactiveState.value.userList ?? defaultFollowingFeedState.userList,
set: value => saveFollowingFilter('userList', value),
});
const withNonPublic: WritableComputedRef<boolean> = computed({
get: () => {
if (userList.value === 'followers') return false;
return storage.value.reactiveState.value.withNonPublic ?? defaultFollowingFeedState.withNonPublic;
},
set: value => saveFollowingFilter('withNonPublic', value),
});
const withQuotes: WritableComputedRef<boolean> = computed({
get: () => storage.value.reactiveState.value.withQuotes ?? defaultFollowingFeedState.withQuotes,
set: value => saveFollowingFilter('withQuotes', value),
});
const withBots: WritableComputedRef<boolean> = computed({
get: () => storage.value.reactiveState.value.withBots ?? defaultFollowingFeedState.withBots,
set: value => saveFollowingFilter('withBots', value),
});
const withReplies: WritableComputedRef<boolean> = computed({
get: () => storage.value.reactiveState.value.withReplies ?? defaultFollowingFeedState.withReplies,
set: value => saveFollowingFilter('withReplies', value),
});
const onlyFiles: WritableComputedRef<boolean> = computed({
get: () => storage.value.reactiveState.value.onlyFiles ?? defaultFollowingFeedState.onlyFiles,
set: value => saveFollowingFilter('onlyFiles', value),
});
const remoteWarningDismissed: WritableComputedRef<boolean> = computed({
get: () => storage.value.reactiveState.value.remoteWarningDismissed ?? defaultFollowingFeedState.remoteWarningDismissed,
set: value => saveFollowingFilter('remoteWarningDismissed', value),
});
return {
userList,
withNonPublic,
withQuotes,
withBots,
withReplies,
onlyFiles,
remoteWarningDismissed,
};
}
function createDefaultStorage() {
return computed(() => ({
state: defaultStore.state.followingFeed,
reactiveState: defaultStore.reactiveState.followingFeed,
save(updated: typeof defaultStore.state.followingFeed) {
return defaultStore.set('followingFeed', updated);
},
}));
}

View file

@ -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]>;
};

View file

@ -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;
});
}

View file

@ -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 (window.document.exitFullscreen != null) {
window.document.exitFullscreen();
return;
}
if (videoEl.webkitExitFullscreen != null) {
videoEl.webkitExitFullscreen();
return;
}
};

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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);
};

View file

@ -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;
};

View file

@ -0,0 +1,161 @@
/*
* 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);
}
/*
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-hash',
text: i18n.ts.copyFileId,
action: () => {
copyToClipboard(file.id);
},
});
}
return menuItems;
}

View file

@ -0,0 +1,86 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAsyncComponent } from 'vue';
import { v4 as uuid } from 'uuid';
import { url } from '@@/js/config.js';
import { defaultEmbedParams, embedRouteWithScrollbar } from '@@/js/embed-page.js';
import type { EmbedParams, EmbeddableEntity } from '@@/js/embed-page.js';
import * as os from '@/os.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.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));
} else {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkEmbedCodeGenDialog.vue')), {
entity,
id,
params: _params,
}, {
closed: () => dispose(),
});
}
}

View file

@ -0,0 +1,699 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js';
import { url } from '@@/js/config.js';
import { claimAchievement } from './achievements.js';
import type { Ref, ShallowRef } from 'vue';
import type { MenuItem } from '@/types/menu.js';
import { $i } from '@/i.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 } 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';
import { getPluginHandlers } from '@/plugin.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}`);
},
};
}
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 edit(): void {
os.post({
initialNote: appearNote,
renote: appearNote.renote,
reply: appearNote.reply,
channel: appearNote.channel,
editId: appearNote.id,
initialFiles: appearNote.files,
});
}
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);
}
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);
},
}, {
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: 'ph-pencil-simple ph-bold ph-lg',
text: i18n.ts.edit,
action: edit,
});
menuItems.push({
icon: 'ti ti-edit',
text: i18n.ts.deleteAndEdit,
danger: true,
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);
},
}, {
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));
}
}
const noteActions = getPluginHandlers('note_action');
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-hash',
text: i18n.ts.copyNoteId,
action: () => {
copyToClipboard(appearNote.id);
},
});
}
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.s.visibility : prefer.s.defaultNoteVisibility;
const localOnly = prefer.s.rememberNoteVisibility ? store.s.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,
};
}

View file

@ -0,0 +1,71 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as Misskey from 'misskey-js';
import { appendContentWarning } from '@@/js/append-content-warning.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 = '';
// Append mandatory CW, if applicable
let cw = note.cw;
if (note.user.mandatoryCW) {
cw = appendContentWarning(cw, note.user.mandatoryCW);
}
// 本文
if (cw != null) {
summary += `CW: ${cw}`;
} else if (note.text) {
summary += note.text;
}
// ファイルが添付されているとき
if (note.files && 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 && !note.cw) {
summary += `\n\nRE: ${getNoteSummary(note.reply)}`;
} else {
summary += '\n\nRE: ...';
}
}
// Renoteのとき
if (note.renoteId) {
if (note.renote && !note.cw) {
summary += `\n\nRN: ${getNoteSummary(note.renote)}`;
} else {
summary += '\n\nRN: ...';
}
}
return summary.trim();
};

View file

@ -0,0 +1,69 @@
/*
* SPDX-FileCopyrightText: marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Ref, defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { misskeyApi } from './misskey-api.js';
import { MenuItem } from '@/types/menu.js';
import { dateTimeFormat } from './intl-const.js';
export async function getNoteVersionsMenu(props: {
note: Misskey.entities.Note;
menuButton: Ref<HTMLElement>;
}) {
const isRenote = (
props.note.renote != null &&
props.note.text == null &&
props.note.fileIds.length === 0 &&
props.note.poll == null
);
const appearNote = isRenote ? props.note.renote as Misskey.entities.Note : props.note;
const cleanups = [] as (() => void)[];
function openVersion(info): void {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/SkOldNoteWindow.vue')), {
note: appearNote,
oldText: info.text,
updatedAt: info.oldDate ? info.oldDate : info.updatedAt,
}, {
closed: () => dispose(),
});
}
const menu: MenuItem[] = [];
const statePromise = misskeyApi('notes/versions', {
noteId: appearNote.id,
});
await statePromise.then((versions) => {
for (const edit of versions) {
const _time = edit.oldDate == null ? NaN :
typeof edit.oldDate === 'number' ? edit.oldDate :
(edit.oldDate instanceof Date ? edit.oldDate : new Date(edit.oldDate)).getTime();
menu.push({
icon: 'ph-pencil-simple ph-bold ph-lg',
text: _time ? dateTimeFormat.format(_time) : dateTimeFormat.format(new Date(edit.updatedAt)),
action: () => openVersion(edit),
});
}
});
const cleanup = () => {
if (_DEV_) console.log('note menu cleanup', cleanups);
for (const cl of cleanups) {
cl();
}
};
return {
menu,
cleanup,
};
}

View file

@ -0,0 +1,446 @@
/*
* 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 { Router } from '@/router.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 { $i, iAmModerator } from '@/i.js';
import { notesSearchAvailable, canSearchNonLocalNotes } from '@/utility/check-permissions.js';
import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
import { mainRouter } from '@/router.js';
import { genEmbedCode } from '@/utility/get-embed-code.js';
import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router = 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: 'question',
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[] = [];
if (iAmModerator) {
menuItems.push({
icon: 'ti ti-user-exclamation',
text: i18n.ts.moderation,
action: () => {
router.push(`/admin/user/${user.id}`);
},
}, { type: 'divider' });
}
menuItems.push({
icon: 'ti ti-at',
text: i18n.ts.copyUsername,
action: () => {
copyToClipboard(`@${user.username}@${user.host ?? host}`);
},
});
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}`);
},
});
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: ユーザーカードの埋め込みなど
});
}
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 ($i) {
menuItems.push({ 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: '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: 'link',
icon: 'ti ti-messages',
text: i18n.ts._chat.chatWithThisUser,
to: `/chat/user/${user.id}`,
}, { 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-hash',
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');
},
});
}
const userActions = getPluginHandlers('user_action');
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();
}
},
};
}

View file

@ -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;
}

View file

@ -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', 'ruffle-player'];
//#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 (window.document.activeElement != null) {
if (IGNORE_ELEMENTS.includes(window.document.activeElement.tagName.toLowerCase())) return;
if (getHTMLElementOrNull(window.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

View file

@ -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}`);
}

View file

@ -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();

View file

@ -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(window.document.documentElement).getPropertyValue('--MI_THEME-fg');
Chart.defaults.borderColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
Chart.defaults.animation = false;
}

View file

@ -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,
});
});
}

View file

@ -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;

View file

@ -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))
);
}

View file

@ -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;
}

View file

@ -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 '@/i.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;
}

View file

@ -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);
}
}
}

View file

@ -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',
},
};

View file

@ -0,0 +1,25 @@
Copyright (c) 2004-2024, OpenMPT Project Developers and Contributors
Copyright (c) 1997-2003, Olivier Lapicque
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the OpenMPT project nor the
names of its contributors may be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,23 @@
modifications made to `libopenmpt.js` (can be taken from https://lib.openmpt.org/libopenmpt/download/):
at the beginning of the file:
```js
// @ts-nocheck
/* eslint-disable */
```
at the end of the file:
```js
Module.UTF8ToString = UTF8ToString;
Module.writeAsciiToMemory = writeAsciiToMemory;
export { Module }
```
replace
```
wasmBinaryFile="libopenmpt.wasm"
```
with
```
wasmBinaryFile=new URL("./libopenmpt.wasm", import.meta.url).href
```

View file

@ -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();
}

View file

@ -0,0 +1,84 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Router } from '@/router.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { mainRouter } from '@/router.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;
}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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');
}

View file

@ -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: 'ph-brackets-curly ph-bold ph-lg',
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);
});
}

View file

@ -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 '@/i.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;
}

View file

@ -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;
}

View file

@ -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: window.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);
},
};
}

View file

@ -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();
}

View file

@ -0,0 +1,76 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { defineAsyncComponent } from 'vue';
import { $i } from '@/i.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');
}

View file

@ -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}`);
}
}

View file

@ -0,0 +1,171 @@
/*
* 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;
const HORIZONTAL_MARGIN = 1;
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 - HORIZONTAL_MARGIN;
}
left = Math.max(HORIZONTAL_MARGIN, left);
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 - HORIZONTAL_MARGIN;
}
left = Math.max(HORIZONTAL_MARGIN, left);
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;
}
left = Math.max(HORIZONTAL_MARGIN, left);
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);
}
left = Math.max(HORIZONTAL_MARGIN, left);
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();
}

View file

@ -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,
}, '*');
}

View file

@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
const CHARS = 'abcdefghijklmnopqrstuvwxyz'; // CSSの<custom-ident>などで使われることもあるのでa-z以外使うな
export function randomId(length = 32, characters = CHARS) {
let result = '';
const charactersLength = characters.length;
for ( let i = 0; i < length; i++ ) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}

View file

@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as Misskey from 'misskey-js';
import { defineAsyncComponent, ref, watch } from 'vue';
import type { Ref } from 'vue';
import { popup } from '@/os.js';
import { prefer } from '@/preferences.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 = ref<string[]>([]);
watch([prefer.r.emojiPaletteForReaction, prefer.r.emojiPalettes], () => {
reactionsRef.value = prefer.s.emojiPaletteForReaction == null ? prefer.s.emojiPalettes[0].emojis : prefer.s.emojiPalettes.find(palette => palette.id === prefer.s.emojiPaletteForReaction)?.emojis ?? [];
}, {
immediate: true,
});
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();

View file

@ -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 {
window.location.reload();
}
}

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: dakkar and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import original from 'sanitize-html';
export default function sanitizeHtml(str: string | null): string | null {
if (str == null) return str;
return original(str, {
allowedTags: original.defaults.allowedTags.concat(['img', 'audio', 'video', 'center', 'details', 'summary']),
allowedAttributes: {
...original.defaults.allowedAttributes,
a: original.defaults.allowedAttributes.a.concat(['style']),
img: original.defaults.allowedAttributes.img.concat(['style']),
'*': (original.defaults.allowedAttributes['*'] || []).concat(['style']),
},
});
}

View file

@ -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.toLowerCase() === 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.toLowerCase() === 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.toLowerCase().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.toLowerCase().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.toLowerCase().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);
}

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: leah and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
//store the URL and if its none of these its a custom one
export const searchEngineMap = {
//The first one is the default search engine
'https://www.google.com/search?q={query}': 'Google',
'https://duckduckgo.com/?q={query}': 'Duckduckgo',
'https://www.bing.com/search?q={query}': 'Bing',
'https://search.yahoo.com/search?p={query}': 'Yahoo',
'https://www.ecosia.org/search?q={query}': 'Ecosia',
'https://www.qwant.com/?q={query}': 'Qwant',
'https://search.aol.com/aol/search?q={query}': 'AOL',
'https://yandex.com/search?text={query}': 'Yandex',
};

View file

@ -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 = window.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);
}

View file

@ -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 '@/i.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');
}

View file

@ -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,
});
}

View file

@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
export function showSystemAccountDialog(): Promise<void> {
return os.alert({
type: 'error',
title: i18n.ts.systemAccountTitle,
text: i18n.ts.systemAccountDescription,
});
}

View file

@ -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;
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,259 @@
/*
* 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',
'chatMessage',
] 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 window.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 URLIDではない
*/
export async function getSoundDuration(file: string): Promise<number> {
const audioEl = window.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'] && window.document.visibilityState === 'hidden') {
// ブラウザがアクティブな時のみサウンドを出力する
return true;
}
return false;
}

View file

@ -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 = window.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;
}
}

View file

@ -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
}
}

View file

@ -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);
}

View file

@ -0,0 +1,87 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { v4 as uuid } from 'uuid';
import type { Theme } from '@/theme.js';
import { themeProps } 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;
};

View file

@ -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]));
}

View file

@ -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,
}];

View file

@ -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);

View file

@ -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);
window.location.href = path;
} else {
reloadChannel.postMessage(null);
window.location.reload();
}
}

View file

@ -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 '@/i.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 | null,
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);
});
}

View file

@ -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,
};
}

View file

@ -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 = window.document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
isWebpSupportedCache = canvas.toDataURL('image/webp').startsWith('data:image/webp');
}
return isWebpSupportedCache;
}

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