From 9384f5399da39e53855beb8e7f8ded1aa56bf72e Mon Sep 17 00:00:00 2001 From: syuilo Date: Tue, 27 Dec 2022 14:36:33 +0900 Subject: rename: client -> frontend --- packages/frontend/src/scripts/2fa.ts | 33 + packages/frontend/src/scripts/aiscript/api.ts | 43 ++ packages/frontend/src/scripts/array.ts | 149 +++++ packages/frontend/src/scripts/autocomplete.ts | 276 +++++++++ packages/frontend/src/scripts/chart-vline.ts | 21 + packages/frontend/src/scripts/check-word-mute.ts | 37 ++ packages/frontend/src/scripts/clone.ts | 18 + packages/frontend/src/scripts/collect-page-vars.ts | 68 +++ packages/frontend/src/scripts/contains.ts | 9 + packages/frontend/src/scripts/copy-to-clipboard.ts | 33 + packages/frontend/src/scripts/device-kind.ts | 10 + packages/frontend/src/scripts/emoji-base.ts | 20 + packages/frontend/src/scripts/emojilist.ts | 17 + .../src/scripts/extract-avg-color-from-blurhash.ts | 9 + packages/frontend/src/scripts/extract-mentions.ts | 11 + .../frontend/src/scripts/extract-url-from-mfm.ts | 19 + packages/frontend/src/scripts/focus.ts | 27 + packages/frontend/src/scripts/form.ts | 59 ++ .../frontend/src/scripts/format-time-string.ts | 50 ++ packages/frontend/src/scripts/gen-search-query.ts | 30 + .../frontend/src/scripts/get-account-from-id.ts | 7 + packages/frontend/src/scripts/get-note-menu.ts | 341 +++++++++++ packages/frontend/src/scripts/get-note-summary.ts | 55 ++ .../frontend/src/scripts/get-static-image-url.ts | 19 + packages/frontend/src/scripts/get-user-menu.ts | 253 ++++++++ packages/frontend/src/scripts/get-user-name.ts | 3 + packages/frontend/src/scripts/hotkey.ts | 90 +++ packages/frontend/src/scripts/hpml/block.ts | 109 ++++ packages/frontend/src/scripts/hpml/evaluator.ts | 232 +++++++ packages/frontend/src/scripts/hpml/expr.ts | 79 +++ packages/frontend/src/scripts/hpml/index.ts | 103 ++++ packages/frontend/src/scripts/hpml/lib.ts | 247 ++++++++ packages/frontend/src/scripts/hpml/type-checker.ts | 191 ++++++ packages/frontend/src/scripts/i18n.ts | 29 + packages/frontend/src/scripts/idb-proxy.ts | 36 ++ packages/frontend/src/scripts/initialize-sw.ts | 13 + .../frontend/src/scripts/is-device-darkmode.ts | 3 + packages/frontend/src/scripts/keycode.ts | 33 + packages/frontend/src/scripts/langmap.ts | 666 +++++++++++++++++++++ packages/frontend/src/scripts/login-id.ts | 11 + packages/frontend/src/scripts/lookup-user.ts | 36 ++ packages/frontend/src/scripts/media-proxy.ts | 15 + packages/frontend/src/scripts/mfm-tags.ts | 1 + packages/frontend/src/scripts/page-metadata.ts | 41 ++ packages/frontend/src/scripts/physics.ts | 152 +++++ packages/frontend/src/scripts/please-login.ts | 21 + packages/frontend/src/scripts/popout.ts | 23 + packages/frontend/src/scripts/popup-position.ts | 158 +++++ packages/frontend/src/scripts/reaction-picker.ts | 41 ++ packages/frontend/src/scripts/safe-uri-decode.ts | 7 + packages/frontend/src/scripts/scroll.ts | 85 +++ packages/frontend/src/scripts/search.ts | 63 ++ packages/frontend/src/scripts/select-file.ts | 103 ++++ .../frontend/src/scripts/show-suspended-dialog.ts | 10 + packages/frontend/src/scripts/shuffle.ts | 19 + packages/frontend/src/scripts/sound.ts | 66 ++ packages/frontend/src/scripts/sticky-sidebar.ts | 50 ++ packages/frontend/src/scripts/theme-editor.ts | 81 +++ packages/frontend/src/scripts/theme.ts | 148 +++++ packages/frontend/src/scripts/time.ts | 39 ++ packages/frontend/src/scripts/timezones.ts | 49 ++ packages/frontend/src/scripts/touch.ts | 23 + packages/frontend/src/scripts/unison-reload.ts | 15 + packages/frontend/src/scripts/upload.ts | 137 +++++ .../frontend/src/scripts/upload/compress-config.ts | 23 + packages/frontend/src/scripts/url.ts | 13 + packages/frontend/src/scripts/use-chart-tooltip.ts | 54 ++ packages/frontend/src/scripts/use-interval.ts | 24 + packages/frontend/src/scripts/use-leave-guard.ts | 47 ++ packages/frontend/src/scripts/use-note-capture.ts | 110 ++++ packages/frontend/src/scripts/use-tooltip.ts | 86 +++ 71 files changed, 5199 insertions(+) create mode 100644 packages/frontend/src/scripts/2fa.ts create mode 100644 packages/frontend/src/scripts/aiscript/api.ts create mode 100644 packages/frontend/src/scripts/array.ts create mode 100644 packages/frontend/src/scripts/autocomplete.ts create mode 100644 packages/frontend/src/scripts/chart-vline.ts create mode 100644 packages/frontend/src/scripts/check-word-mute.ts create mode 100644 packages/frontend/src/scripts/clone.ts create mode 100644 packages/frontend/src/scripts/collect-page-vars.ts create mode 100644 packages/frontend/src/scripts/contains.ts create mode 100644 packages/frontend/src/scripts/copy-to-clipboard.ts create mode 100644 packages/frontend/src/scripts/device-kind.ts create mode 100644 packages/frontend/src/scripts/emoji-base.ts create mode 100644 packages/frontend/src/scripts/emojilist.ts create mode 100644 packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts create mode 100644 packages/frontend/src/scripts/extract-mentions.ts create mode 100644 packages/frontend/src/scripts/extract-url-from-mfm.ts create mode 100644 packages/frontend/src/scripts/focus.ts create mode 100644 packages/frontend/src/scripts/form.ts create mode 100644 packages/frontend/src/scripts/format-time-string.ts create mode 100644 packages/frontend/src/scripts/gen-search-query.ts create mode 100644 packages/frontend/src/scripts/get-account-from-id.ts create mode 100644 packages/frontend/src/scripts/get-note-menu.ts create mode 100644 packages/frontend/src/scripts/get-note-summary.ts create mode 100644 packages/frontend/src/scripts/get-static-image-url.ts create mode 100644 packages/frontend/src/scripts/get-user-menu.ts create mode 100644 packages/frontend/src/scripts/get-user-name.ts create mode 100644 packages/frontend/src/scripts/hotkey.ts create mode 100644 packages/frontend/src/scripts/hpml/block.ts create mode 100644 packages/frontend/src/scripts/hpml/evaluator.ts create mode 100644 packages/frontend/src/scripts/hpml/expr.ts create mode 100644 packages/frontend/src/scripts/hpml/index.ts create mode 100644 packages/frontend/src/scripts/hpml/lib.ts create mode 100644 packages/frontend/src/scripts/hpml/type-checker.ts create mode 100644 packages/frontend/src/scripts/i18n.ts create mode 100644 packages/frontend/src/scripts/idb-proxy.ts create mode 100644 packages/frontend/src/scripts/initialize-sw.ts create mode 100644 packages/frontend/src/scripts/is-device-darkmode.ts create mode 100644 packages/frontend/src/scripts/keycode.ts create mode 100644 packages/frontend/src/scripts/langmap.ts create mode 100644 packages/frontend/src/scripts/login-id.ts create mode 100644 packages/frontend/src/scripts/lookup-user.ts create mode 100644 packages/frontend/src/scripts/media-proxy.ts create mode 100644 packages/frontend/src/scripts/mfm-tags.ts create mode 100644 packages/frontend/src/scripts/page-metadata.ts create mode 100644 packages/frontend/src/scripts/physics.ts create mode 100644 packages/frontend/src/scripts/please-login.ts create mode 100644 packages/frontend/src/scripts/popout.ts create mode 100644 packages/frontend/src/scripts/popup-position.ts create mode 100644 packages/frontend/src/scripts/reaction-picker.ts create mode 100644 packages/frontend/src/scripts/safe-uri-decode.ts create mode 100644 packages/frontend/src/scripts/scroll.ts create mode 100644 packages/frontend/src/scripts/search.ts create mode 100644 packages/frontend/src/scripts/select-file.ts create mode 100644 packages/frontend/src/scripts/show-suspended-dialog.ts create mode 100644 packages/frontend/src/scripts/shuffle.ts create mode 100644 packages/frontend/src/scripts/sound.ts create mode 100644 packages/frontend/src/scripts/sticky-sidebar.ts create mode 100644 packages/frontend/src/scripts/theme-editor.ts create mode 100644 packages/frontend/src/scripts/theme.ts create mode 100644 packages/frontend/src/scripts/time.ts create mode 100644 packages/frontend/src/scripts/timezones.ts create mode 100644 packages/frontend/src/scripts/touch.ts create mode 100644 packages/frontend/src/scripts/unison-reload.ts create mode 100644 packages/frontend/src/scripts/upload.ts create mode 100644 packages/frontend/src/scripts/upload/compress-config.ts create mode 100644 packages/frontend/src/scripts/url.ts create mode 100644 packages/frontend/src/scripts/use-chart-tooltip.ts create mode 100644 packages/frontend/src/scripts/use-interval.ts create mode 100644 packages/frontend/src/scripts/use-leave-guard.ts create mode 100644 packages/frontend/src/scripts/use-note-capture.ts create mode 100644 packages/frontend/src/scripts/use-tooltip.ts (limited to 'packages/frontend/src/scripts') diff --git a/packages/frontend/src/scripts/2fa.ts b/packages/frontend/src/scripts/2fa.ts new file mode 100644 index 0000000000..62a38ff02a --- /dev/null +++ b/packages/frontend/src/scripts/2fa.ts @@ -0,0 +1,33 @@ +export function byteify(string: string, encoding: 'ascii' | 'base64' | 'hex') { + switch (encoding) { + case 'ascii': + return Uint8Array.from(string, c => c.charCodeAt(0)); + case 'base64': + return Uint8Array.from( + atob( + string + .replace(/-/g, '+') + .replace(/_/g, '/'), + ), + c => c.charCodeAt(0), + ); + case 'hex': + return new Uint8Array( + string + .match(/.{1,2}/g) + .map(byte => parseInt(byte, 16)), + ); + } +} + +export function hexify(buffer: ArrayBuffer) { + return Array.from(new Uint8Array(buffer)) + .reduce( + (str, byte) => str + byte.toString(16).padStart(2, '0'), + '', + ); +} + +export function stringify(buffer: ArrayBuffer) { + return String.fromCharCode(... new Uint8Array(buffer)); +} diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/scripts/aiscript/api.ts new file mode 100644 index 0000000000..6debcb8a13 --- /dev/null +++ b/packages/frontend/src/scripts/aiscript/api.ts @@ -0,0 +1,43 @@ +import { utils, values } from '@syuilo/aiscript'; +import * as os from '@/os'; +import { $i } from '@/account'; + +export function createAiScriptEnv(opts) { + let apiRequests = 0; + return { + USER_ID: $i ? values.STR($i.id) : values.NULL, + USER_NAME: $i ? values.STR($i.name) : values.NULL, + USER_USERNAME: $i ? values.STR($i.username) : values.NULL, + 'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => { + await os.alert({ + type: type ? type.value : 'info', + title: title.value, + text: text.value, + }); + }), + 'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => { + const confirm = await os.confirm({ + type: type ? type.value : 'question', + title: title.value, + text: text.value, + }); + return confirm.canceled ? values.FALSE : values.TRUE; + }), + 'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => { + if (token) utils.assertString(token); + apiRequests++; + if (apiRequests > 16) return values.NULL; + const res = await os.api(ep.value, utils.valToJs(param), token ? token.value : (opts.token ?? null)); + return utils.jsToVal(res); + }), + 'Mk:save': values.FN_NATIVE(([key, value]) => { + utils.assertString(key); + localStorage.setItem('aiscript:' + opts.storageKey + ':' + key.value, JSON.stringify(utils.valToJs(value))); + return values.NULL; + }), + 'Mk:load': values.FN_NATIVE(([key]) => { + utils.assertString(key); + return utils.jsToVal(JSON.parse(localStorage.getItem('aiscript:' + opts.storageKey + ':' + key.value))); + }), + }; +} diff --git a/packages/frontend/src/scripts/array.ts b/packages/frontend/src/scripts/array.ts new file mode 100644 index 0000000000..4620c8b735 --- /dev/null +++ b/packages/frontend/src/scripts/array.ts @@ -0,0 +1,149 @@ +import { EndoRelation, Predicate } from './relation'; + +/** + * Count the number of elements that satisfy the predicate + */ + +export function countIf(f: Predicate, xs: T[]): number { + return xs.filter(f).length; +} + +/** + * Count the number of elements that is equal to the element + */ +export function count(a: T, xs: T[]): number { + return countIf(x => x === a, xs); +} + +/** + * Concatenate an array of arrays + */ +export function concat(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(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(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(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(xs: T[]): T[] { + return [...new Set(xs)]; +} + +export function uniqueBy(values: TValue[], keySelector: (value: TValue) => TKey): TValue[] { + const map = new Map(); + + 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); +} + +/** + * Splits an array based on the equivalence relation. + * The concatenation of the result is equal to the argument. + */ +export function groupBy(f: EndoRelation, xs: T[]): T[][] { + const groups = [] as T[][]; + for (const x of xs) { + if (groups.length !== 0 && f(groups[groups.length - 1][0], x)) { + groups[groups.length - 1].push(x); + } else { + groups.push([x]); + } + } + return groups; +} + +/** + * Splits an array based on the equivalence relation induced by the function. + * The concatenation of the result is equal to the argument. + */ +export function groupOn(f: (x: T) => S, xs: T[]): T[][] { + return groupBy((a, b) => f(a) === f(b), xs); +} + +export function groupByX(collections: T[], keySelector: (x: T) => string) { + return collections.reduce((obj: Record, item: T) => { + const key = keySelector(item); + if (typeof obj[key] === 'undefined') { + obj[key] = []; + } + + obj[key].push(item); + + return obj; + }, {}); +} + +/** + * 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(f: Predicate, 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(x: T | T[] | undefined): T[] { + return Array.isArray(x) ? x : x != null ? [x] : []; +} + +export function toSingle(x: T | T[] | undefined): T | undefined { + return Array.isArray(x) ? x[0] : x; +} diff --git a/packages/frontend/src/scripts/autocomplete.ts b/packages/frontend/src/scripts/autocomplete.ts new file mode 100644 index 0000000000..1bae3790f5 --- /dev/null +++ b/packages/frontend/src/scripts/autocomplete.ts @@ -0,0 +1,276 @@ +import { nextTick, Ref, ref, defineAsyncComponent } from 'vue'; +import getCaretCoordinates from 'textarea-caret'; +import { toASCII } from 'punycode/'; +import { popup } from '@/os'; + +export class Autocomplete { + private suggestion: { + x: Ref; + y: Ref; + q: Ref; + close: () => void; + } | null; + private textarea: HTMLInputElement | HTMLTextAreaElement; + private currentType: string; + private textRef: Ref; + private opening: boolean; + + 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) { + //#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.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.substr(0, caretPos).split('\n').pop()!; + + const mentionIndex = text.lastIndexOf('@'); + const hashtagIndex = text.lastIndexOf('#'); + const emojiIndex = text.lastIndexOf(':'); + const mfmTagIndex = text.lastIndexOf('$'); + + const max = Math.max( + mentionIndex, + hashtagIndex, + emojiIndex, + mfmTagIndex); + + if (max === -1) { + this.close(); + return; + } + + const isMention = mentionIndex !== -1; + const isHashtag = hashtagIndex !== -1; + const isMfmTag = mfmTagIndex !== -1; + const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':'); + + let opened = false; + + if (isMention) { + const username = text.substr(mentionIndex + 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) { + const hashtag = text.substr(hashtagIndex + 1); + if (!hashtag.includes(' ')) { + this.open('hashtag', hashtag); + opened = true; + } + } + + if (isEmoji && !opened) { + const emoji = text.substr(emojiIndex + 1); + if (!emoji.includes(' ')) { + this.open('emoji', emoji); + opened = true; + } + } + + if (isMfmTag && !opened) { + const mfmTag = text.substr(mfmTagIndex + 1); + if (!mfmTag.includes(' ')) { + this.open('mfmTag', mfmTag.replace('[', '')); + opened = true; + } + } + + if (!opened) { + this.close(); + } + } + + /** + * サジェストを提示します。 + */ + private async open(type: string, q: string | null) { + 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.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('@')); + const after = source.substr(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.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('#')); + const after = source.substr(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.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf(':')); + const after = source.substr(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.substr(0, caret); + const trimmedBefore = before.substring(0, before.lastIndexOf('$')); + const after = source.substr(caret); + + // 挿入 + this.text = `${trimmedBefore}$[${value} ]${after}`; + + // キャレットを戻す + nextTick(() => { + this.textarea.focus(); + const pos = trimmedBefore.length + (value.length + 3); + this.textarea.setSelectionRange(pos, pos); + }); + } + } +} diff --git a/packages/frontend/src/scripts/chart-vline.ts b/packages/frontend/src/scripts/chart-vline.ts new file mode 100644 index 0000000000..8e3c4436b2 --- /dev/null +++ b/packages/frontend/src/scripts/chart-vline.ts @@ -0,0 +1,21 @@ +export const chartVLine = (vLineColor: string) => ({ + id: 'vLine', + beforeDraw(chart, args, options) { + if (chart.tooltip?._active?.length) { + const activePoint = chart.tooltip._active[0]; + const ctx = chart.ctx; + const x = activePoint.element.x; + 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(); + } + }, +}); diff --git a/packages/frontend/src/scripts/check-word-mute.ts b/packages/frontend/src/scripts/check-word-mute.ts new file mode 100644 index 0000000000..35d40a6e08 --- /dev/null +++ b/packages/frontend/src/scripts/check-word-mute.ts @@ -0,0 +1,37 @@ +export function checkWordMute(note: Record, me: Record | null | undefined, mutedWords: Array): boolean { + // 自分自身 + if (me && (note.userId === me.id)) return false; + + if (mutedWords.length > 0) { + const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim(); + + if (text === '') return false; + + const matched = mutedWords.some(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) return true; + } + + return false; +} diff --git a/packages/frontend/src/scripts/clone.ts b/packages/frontend/src/scripts/clone.ts new file mode 100644 index 0000000000..16fad24129 --- /dev/null +++ b/packages/frontend/src/scripts/clone.ts @@ -0,0 +1,18 @@ +// structredCloneが遅いため +// SEE: http://var.blog.jp/archives/86038606.html + +type Cloneable = string | number | boolean | null | { [key: string]: Cloneable } | Cloneable[]; + +export function deepClone(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; + for (const [k, v] of Object.entries(x)) { + obj[k] = deepClone(v); + } + return obj as T; + } else { + return x; + } +} diff --git a/packages/frontend/src/scripts/collect-page-vars.ts b/packages/frontend/src/scripts/collect-page-vars.ts new file mode 100644 index 0000000000..76b68beaf6 --- /dev/null +++ b/packages/frontend/src/scripts/collect-page-vars.ts @@ -0,0 +1,68 @@ +interface StringPageVar { + name: string, + type: 'string', + value: string +} + +interface NumberPageVar { + name: string, + type: 'number', + value: number +} + +interface BooleanPageVar { + name: string, + type: 'boolean', + value: boolean +} + +type PageVar = StringPageVar | NumberPageVar | BooleanPageVar; + +export function collectPageVars(content): PageVar[] { + const pageVars: PageVar[] = []; + const collect = (xs: any[]): void => { + for (const x of xs) { + if (x.type === 'textInput') { + pageVars.push({ + name: x.name, + type: 'string', + value: x.default || '', + }); + } else if (x.type === 'textareaInput') { + pageVars.push({ + name: x.name, + type: 'string', + value: x.default || '', + }); + } else if (x.type === 'numberInput') { + pageVars.push({ + name: x.name, + type: 'number', + value: x.default || 0, + }); + } else if (x.type === 'switch') { + pageVars.push({ + name: x.name, + type: 'boolean', + value: x.default || false, + }); + } else if (x.type === 'counter') { + pageVars.push({ + name: x.name, + type: 'number', + value: 0, + }); + } else if (x.type === 'radioButton') { + pageVars.push({ + name: x.name, + type: 'string', + value: x.default || '', + }); + } else if (x.children) { + collect(x.children); + } + } + }; + collect(content); + return pageVars; +} diff --git a/packages/frontend/src/scripts/contains.ts b/packages/frontend/src/scripts/contains.ts new file mode 100644 index 0000000000..256e09d293 --- /dev/null +++ b/packages/frontend/src/scripts/contains.ts @@ -0,0 +1,9 @@ +export default (parent, child, checkSame = true) => { + if (checkSame && parent === child) return true; + let node = child.parentNode; + while (node) { + if (node === parent) return true; + node = node.parentNode; + } + return false; +}; diff --git a/packages/frontend/src/scripts/copy-to-clipboard.ts b/packages/frontend/src/scripts/copy-to-clipboard.ts new file mode 100644 index 0000000000..ab13cab970 --- /dev/null +++ b/packages/frontend/src/scripts/copy-to-clipboard.ts @@ -0,0 +1,33 @@ +/** + * Clipboardに値をコピー(TODO: 文字列以外も対応) + */ +export default val => { + // 空div 生成 + const tmp = document.createElement('div'); + // 選択用のタグ生成 + const pre = document.createElement('pre'); + + // 親要素のCSSで user-select: none だとコピーできないので書き換える + pre.style.webkitUserSelect = 'auto'; + pre.style.userSelect = 'auto'; + + tmp.appendChild(pre).textContent = val; + + // 要素を画面外へ + const s = tmp.style; + s.position = 'fixed'; + s.right = '200%'; + + // body に追加 + document.body.appendChild(tmp); + // 要素を選択 + document.getSelection().selectAllChildren(tmp); + + // クリップボードにコピー + const result = document.execCommand('copy'); + + // 要素削除 + document.body.removeChild(tmp); + + return result; +}; diff --git a/packages/frontend/src/scripts/device-kind.ts b/packages/frontend/src/scripts/device-kind.ts new file mode 100644 index 0000000000..544cac0604 --- /dev/null +++ b/packages/frontend/src/scripts/device-kind.ts @@ -0,0 +1,10 @@ +import { defaultStore } from '@/store'; + +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 deviceKind = defaultStore.state.overridedDeviceKind ? defaultStore.state.overridedDeviceKind + : isSmartphone ? 'smartphone' + : isTablet ? 'tablet' + : 'desktop'; diff --git a/packages/frontend/src/scripts/emoji-base.ts b/packages/frontend/src/scripts/emoji-base.ts new file mode 100644 index 0000000000..3f05642d57 --- /dev/null +++ b/packages/frontend/src/scripts/emoji-base.ts @@ -0,0 +1,20 @@ +const twemojiSvgBase = '/twemoji'; +const fluentEmojiPngBase = '/fluent-emoji'; + +export function char2twemojiFilePath(char: string): string { + let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16)); + if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f'); + codes = codes.filter(x => x && x.length); + const fileName = codes.join('-'); + return `${twemojiSvgBase}/${fileName}.svg`; +} + +export function char2fluentEmojiFilePath(char: string): string { + let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16)); + // Fluent Emojiは国旗非対応 https://github.com/microsoft/fluentui-emoji/issues/25 + if (codes[0]?.startsWith('1f1')) return char2twemojiFilePath(char); + if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f'); + codes = codes.filter(x => x && x.length); + const fileName = codes.map(x => x!.padStart(4, '0')).join('-'); + return `${fluentEmojiPngBase}/${fileName}.png`; +} diff --git a/packages/frontend/src/scripts/emojilist.ts b/packages/frontend/src/scripts/emojilist.ts new file mode 100644 index 0000000000..bc52fa7a43 --- /dev/null +++ b/packages/frontend/src/scripts/emojilist.ts @@ -0,0 +1,17 @@ +export const unicodeEmojiCategories = ['face', 'people', 'animals_and_nature', 'food_and_drink', 'activity', 'travel_and_places', 'objects', 'symbols', 'flags'] as const; + +export type UnicodeEmojiDef = { + name: string; + keywords: string[]; + char: string; + category: typeof unicodeEmojiCategories[number]; +} + +// initial converted from https://github.com/muan/emojilib/commit/242fe68be86ed6536843b83f7e32f376468b38fb +import _emojilist from '../emojilist.json'; + +export const emojilist = _emojilist as UnicodeEmojiDef[]; + +export function getEmojiName(char: string): string | undefined { + return emojilist.find(emo => emo.char === char)?.name; +} diff --git a/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts b/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts new file mode 100644 index 0000000000..af517f2672 --- /dev/null +++ b/packages/frontend/src/scripts/extract-avg-color-from-blurhash.ts @@ -0,0 +1,9 @@ +export function extractAvgColorFromBlurhash(hash: string) { + return typeof hash === 'string' + ? '#' + [...hash.slice(2, 6)] + .map(x => '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~'.indexOf(x)) + .reduce((a, c) => a * 83 + c, 0) + .toString(16) + .padStart(6, '0') + : undefined; +} diff --git a/packages/frontend/src/scripts/extract-mentions.ts b/packages/frontend/src/scripts/extract-mentions.ts new file mode 100644 index 0000000000..cc19b161a8 --- /dev/null +++ b/packages/frontend/src/scripts/extract-mentions.ts @@ -0,0 +1,11 @@ +// test is located in test/extract-mentions + +import * as mfm from 'mfm-js'; + +export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] { + // TODO: 重複を削除 + const mentionNodes = mfm.extract(nodes, (node) => node.type === 'mention'); + const mentions = mentionNodes.map(x => x.props); + + return mentions; +} diff --git a/packages/frontend/src/scripts/extract-url-from-mfm.ts b/packages/frontend/src/scripts/extract-url-from-mfm.ts new file mode 100644 index 0000000000..34e3eb6c19 --- /dev/null +++ b/packages/frontend/src/scripts/extract-url-from-mfm.ts @@ -0,0 +1,19 @@ +import * as mfm from 'mfm-js'; +import { unique } from '@/scripts/array'; + +// unique without hash +// [ http://a/#1, http://a/#2, http://b/#3 ] => [ http://a/#1, http://b/#3 ] +const removeHash = (x: string) => x.replace(/#[^#]*$/, ''); + +export function extractUrlFromMfm(nodes: mfm.MfmNode[], respectSilentFlag = true): string[] { + const urlNodes = mfm.extract(nodes, (node) => { + return (node.type === 'url') || (node.type === 'link' && (!respectSilentFlag || !node.props.silent)); + }); + const urls: string[] = unique(urlNodes.map(x => x.props.url)); + + return urls.reduce((array, url) => { + const urlWithoutHash = removeHash(url); + if (!array.map(x => removeHash(x)).includes(urlWithoutHash)) array.push(url); + return array; + }, [] as string[]); +} diff --git a/packages/frontend/src/scripts/focus.ts b/packages/frontend/src/scripts/focus.ts new file mode 100644 index 0000000000..d6802fa322 --- /dev/null +++ b/packages/frontend/src/scripts/focus.ts @@ -0,0 +1,27 @@ +export function focusPrev(el: Element | null, self = false, scroll = true) { + if (el == null) return; + if (!self) el = el.previousElementSibling; + if (el) { + if (el.hasAttribute('tabindex')) { + (el as HTMLElement).focus({ + preventScroll: !scroll, + }); + } else { + focusPrev(el.previousElementSibling, true); + } + } +} + +export function focusNext(el: Element | null, self = false, scroll = true) { + if (el == null) return; + if (!self) el = el.nextElementSibling; + if (el) { + if (el.hasAttribute('tabindex')) { + (el as HTMLElement).focus({ + preventScroll: !scroll, + }); + } else { + focusPrev(el.nextElementSibling, true); + } + } +} diff --git a/packages/frontend/src/scripts/form.ts b/packages/frontend/src/scripts/form.ts new file mode 100644 index 0000000000..7f321cc0ae --- /dev/null +++ b/packages/frontend/src/scripts/form.ts @@ -0,0 +1,59 @@ +export type FormItem = { + label?: string; + type: 'string'; + default: string | null; + hidden?: boolean; + multiline?: boolean; +} | { + label?: string; + type: 'number'; + default: number | null; + hidden?: boolean; + step?: number; +} | { + label?: string; + type: 'boolean'; + default: boolean | null; + hidden?: boolean; +} | { + label?: string; + type: 'enum'; + default: string | null; + hidden?: boolean; + enum: string[]; +} | { + label?: string; + type: 'radio'; + default: unknown | null; + hidden?: boolean; + options: { + label: string; + value: unknown; + }[]; +} | { + label?: string; + type: 'object'; + default: Record | null; + hidden: true; +} | { + label?: string; + type: 'array'; + default: unknown[] | null; + hidden: true; +}; + +export type Form = Record; + +type GetItemType = + Item['type'] extends 'string' ? string : + Item['type'] extends 'number' ? number : + Item['type'] extends 'boolean' ? boolean : + Item['type'] extends 'radio' ? unknown : + Item['type'] extends 'enum' ? string : + Item['type'] extends 'array' ? unknown[] : + Item['type'] extends 'object' ? Record + : never; + +export type GetFormResultType = { + [P in keyof F]: GetItemType; +}; diff --git a/packages/frontend/src/scripts/format-time-string.ts b/packages/frontend/src/scripts/format-time-string.ts new file mode 100644 index 0000000000..c20db5e827 --- /dev/null +++ b/packages/frontend/src/scripts/format-time-string.ts @@ -0,0 +1,50 @@ +const defaultLocaleStringFormats: {[index: string]: string} = { + 'weekday': 'narrow', + 'era': 'narrow', + 'year': 'numeric', + 'month': 'numeric', + 'day': 'numeric', + 'hour': 'numeric', + 'minute': 'numeric', + 'second': 'numeric', + 'timeZoneName': 'short', +}; + +function formatLocaleString(date: Date, format: string): string { + return format.replace(/\{\{(\w+)(:(\w+))?\}\}/g, (match: string, kind: string, unused?, option?: string) => { + if (['weekday', 'era', 'year', 'month', 'day', 'hour', 'minute', 'second', 'timeZoneName'].includes(kind)) { + return date.toLocaleString(window.navigator.language, { [kind]: option ? option : defaultLocaleStringFormats[kind] }); + } else { + return match; + } + }); +} + +export function formatDateTimeString(date: Date, format: string): string { + return format + .replace(/yyyy/g, date.getFullYear().toString()) + .replace(/yy/g, date.getFullYear().toString().slice(-2)) + .replace(/MMMM/g, date.toLocaleString(window.navigator.language, { month: 'long' })) + .replace(/MMM/g, date.toLocaleString(window.navigator.language, { month: 'short' })) + .replace(/MM/g, (`0${date.getMonth() + 1}`).slice(-2)) + .replace(/M/g, (date.getMonth() + 1).toString()) + .replace(/dd/g, (`0${date.getDate()}`).slice(-2)) + .replace(/d/g, date.getDate().toString()) + .replace(/HH/g, (`0${date.getHours()}`).slice(-2)) + .replace(/H/g, date.getHours().toString()) + .replace(/hh/g, (`0${(date.getHours() % 12) || 12}`).slice(-2)) + .replace(/h/g, ((date.getHours() % 12) || 12).toString()) + .replace(/mm/g, (`0${date.getMinutes()}`).slice(-2)) + .replace(/m/g, date.getMinutes().toString()) + .replace(/ss/g, (`0${date.getSeconds()}`).slice(-2)) + .replace(/s/g, date.getSeconds().toString()) + .replace(/tt/g, date.getHours() >= 12 ? 'PM' : 'AM'); +} + +export function formatTimeString(date: Date, format: string): string { + return format.replace(/\[(([^\[]|\[\])*)\]|(([yMdHhmst])\4{0,3})/g, (match: string, localeformat?: string, unused?, datetimeformat?: string) => { + if (localeformat) return formatLocaleString(date, localeformat); + if (datetimeformat) return formatDateTimeString(date, datetimeformat); + return match; + }); +} diff --git a/packages/frontend/src/scripts/gen-search-query.ts b/packages/frontend/src/scripts/gen-search-query.ts new file mode 100644 index 0000000000..da7d622632 --- /dev/null +++ b/packages/frontend/src/scripts/gen-search-query.ts @@ -0,0 +1,30 @@ +import * as Acct from 'misskey-js/built/acct'; +import { host as localHost } from '@/config'; + +export async function genSearchQuery(v: any, q: string) { + let host: string; + let userId: string; + if (q.split(' ').some(x => x.startsWith('@'))) { + for (const at of q.split(' ').filter(x => x.startsWith('@')).map(x => x.substr(1))) { + if (at.includes('.')) { + if (at === localHost || at === '.') { + host = null; + } else { + host = at; + } + } else { + const user = await v.os.api('users/show', Acct.parse(at)).catch(x => null); + if (user) { + userId = user.id; + } else { + // todo: show error + } + } + } + } + return { + query: q.split(' ').filter(x => !x.startsWith('/') && !x.startsWith('@')).join(' '), + host: host, + userId: userId, + }; +} diff --git a/packages/frontend/src/scripts/get-account-from-id.ts b/packages/frontend/src/scripts/get-account-from-id.ts new file mode 100644 index 0000000000..1da897f176 --- /dev/null +++ b/packages/frontend/src/scripts/get-account-from-id.ts @@ -0,0 +1,7 @@ +import { get } from '@/scripts/idb-proxy'; + +export async function getAccountFromId(id: string) { + const accounts = await get('accounts') as { token: string; id: string; }[]; + if (!accounts) console.log('Accounts are not recorded'); + return accounts.find(account => account.id === id); +} diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts new file mode 100644 index 0000000000..7656770894 --- /dev/null +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -0,0 +1,341 @@ +import { defineAsyncComponent, Ref, inject } from 'vue'; +import * as misskey from 'misskey-js'; +import { pleaseLogin } from './please-login'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +import * as os from '@/os'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { url } from '@/config'; +import { noteActions } from '@/store'; +import { notePage } from '@/filters/note'; + +export function getNoteMenu(props: { + note: misskey.entities.Note; + menuButton: Ref; + translation: Ref; + translating: Ref; + isDeleted: Ref; + currentClipPage?: Ref; +}) { + 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; + + function del(): void { + os.confirm({ + type: 'warning', + text: i18n.ts.noteDeleteConfirm, + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: appearNote.id, + }); + }); + } + + function delEdit(): void { + os.confirm({ + type: 'warning', + text: i18n.ts.deleteAndEditConfirm, + }).then(({ canceled }) => { + if (canceled) return; + + os.api('notes/delete', { + noteId: appearNote.id, + }); + + os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel }); + }); + } + + function toggleFavorite(favorite: boolean): void { + os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { + noteId: appearNote.id, + }); + } + + function toggleThreadMute(mute: boolean): void { + os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { + noteId: appearNote.id, + }); + } + + function copyContent(): void { + copyToClipboard(appearNote.text); + os.success(); + } + + function copyLink(): void { + copyToClipboard(`${url}/notes/${appearNote.id}`); + os.success(); + } + + function togglePin(pin: boolean): void { + os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { + noteId: appearNote.id, + }, undefined, null, res => { + if (res.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { + os.alert({ + type: 'error', + text: i18n.ts.pinLimitExceeded, + }); + } + }); + } + + async function clip(): Promise { + const clips = await os.api('clips/list'); + os.popupMenu([{ + icon: 'ti ti-plus', + text: i18n.ts.createNew, + action: async () => { + const { canceled, result } = await os.form(i18n.ts.createNewClip, { + name: { + type: 'string', + label: i18n.ts.name, + }, + description: { + type: 'string', + required: false, + 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); + + os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); + }, + }, null, ...clips.map(clip => ({ + text: clip.name, + action: () => { + os.promiseDialog( + os.api('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.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }), + }); + if (!confirm.canceled) { + os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }); + if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true; + } + } else { + os.alert({ + type: 'error', + text: err.message + '\n' + err.id, + }); + } + }, + ); + }, + }))], props.menuButton.value, { + }).then(focus); + } + + async function unclip(): Promise { + os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id }); + props.isDeleted.value = true; + } + + async function promote(): Promise { + const { canceled, result: days } = await os.inputNumber({ + title: i18n.ts.numberOfDays, + }); + + if (canceled) return; + + os.apiWithDialog('admin/promo/create', { + noteId: appearNote.id, + expiresAt: Date.now() + (86400000 * days), + }); + } + + function share(): void { + navigator.share({ + title: i18n.t('noteOf', { user: appearNote.user.name }), + text: appearNote.text, + url: `${url}/notes/${appearNote.id}`, + }); + } + function notedetails(): void { + os.pageWindow(`/notes/${appearNote.id}`); + } + async function translate(): Promise { + if (props.translation.value != null) return; + props.translating.value = true; + const res = await os.api('notes/translate', { + noteId: appearNote.id, + targetLang: localStorage.getItem('lang') || navigator.language, + }); + props.translating.value = false; + props.translation.value = res; + } + + let menu; + if ($i) { + const statePromise = os.api('notes/state', { + noteId: appearNote.id, + }); + + menu = [ + ...( + props.currentClipPage?.value.userId === $i.id ? [{ + icon: 'ti ti-backspace', + text: i18n.ts.unclip, + danger: true, + action: unclip, + }, null] : [] + ), { + icon: 'ti ti-external-link', + text: i18n.ts.details, + action: notedetails, + }, { + icon: 'ti ti-copy', + text: i18n.ts.copyContent, + action: copyContent, + }, { + icon: 'ti ti-link', + text: i18n.ts.copyLink, + action: copyLink, + }, (appearNote.url || appearNote.uri) ? { + icon: 'ti ti-external-link', + text: i18n.ts.showOnRemote, + action: () => { + window.open(appearNote.url || appearNote.uri, '_blank'); + }, + } : undefined, + { + icon: 'ti ti-share', + text: i18n.ts.share, + action: share, + }, + instance.translatorAvailable ? { + icon: 'ti ti-language-hiragana', + text: i18n.ts.translate, + action: translate, + } : undefined, + null, + 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), + }), + { + icon: 'ti ti-paperclip', + text: i18n.ts.clip, + action: () => clip(), + }, + 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), + }), + appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? { + icon: 'ti ti-pinned-off', + text: i18n.ts.unpin, + action: () => togglePin(false), + } : { + icon: 'ti ti-pin', + text: i18n.ts.pin, + action: () => togglePin(true), + } : undefined, + /* + ...($i.isModerator || $i.isAdmin ? [ + null, + { + icon: 'fas fa-bullhorn', + text: i18n.ts.promote, + action: promote + }] + : [] + ),*/ + ...(appearNote.userId !== $i.id ? [ + null, + { + icon: 'ti ti-exclamation-circle', + text: i18n.ts.reportAbuse, + action: () => { + const u = appearNote.url || appearNote.uri || `${url}/notes/${appearNote.id}`; + os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: appearNote.user, + initialComment: `Note: ${u}\n-----\n`, + }, {}, 'closed'); + }, + }] + : [] + ), + ...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [ + null, + appearNote.userId === $i.id ? { + icon: 'ti ti-edit', + text: i18n.ts.deleteAndEdit, + action: delEdit, + } : undefined, + { + icon: 'ti ti-trash', + text: i18n.ts.delete, + danger: true, + action: del, + }] + : [] + )] + .filter(x => x !== undefined); + } else { + menu = [{ + icon: 'ti ti-external-link', + text: i18n.ts.detailed, + action: openDetail, + }, { + icon: 'ti ti-copy', + text: i18n.ts.copyContent, + action: copyContent, + }, { + icon: 'ti ti-link', + text: i18n.ts.copyLink, + action: copyLink, + }, (appearNote.url || appearNote.uri) ? { + icon: 'ti ti-external-link', + text: i18n.ts.showOnRemote, + action: () => { + window.open(appearNote.url || appearNote.uri, '_blank'); + }, + } : undefined] + .filter(x => x !== undefined); + } + + if (noteActions.length > 0) { + menu = menu.concat([null, ...noteActions.map(action => ({ + icon: 'ti ti-plug', + text: action.title, + action: () => { + action.handler(appearNote); + }, + }))]); + } + + return menu; +} diff --git a/packages/frontend/src/scripts/get-note-summary.ts b/packages/frontend/src/scripts/get-note-summary.ts new file mode 100644 index 0000000000..d57e1c3029 --- /dev/null +++ b/packages/frontend/src/scripts/get-note-summary.ts @@ -0,0 +1,55 @@ +import * as misskey from 'misskey-js'; +import { i18n } from '@/i18n'; + +/** + * 投稿を表す文字列を取得します。 + * @param {*} note (packされた)投稿 + */ +export const getNoteSummary = (note: misskey.entities.Note): string => { + if (note.deletedAt) { + return `(${i18n.ts.deletedNote})`; + } + + if (note.isHidden) { + return `(${i18n.ts.invisibleNote})`; + } + + let summary = ''; + + // 本文 + if (note.cw != null) { + summary += note.cw; + } else { + summary += note.text ? note.text : ''; + } + + // ファイルが添付されているとき + if ((note.files || []).length !== 0) { + summary += ` (${i18n.t('withNFiles', { n: note.files.length })})`; + } + + // 投票が添付されているとき + if (note.poll) { + summary += ` (${i18n.ts.poll})`; + } + + // 返信のとき + if (note.replyId) { + if (note.reply) { + summary += `\n\nRE: ${getNoteSummary(note.reply)}`; + } else { + summary += '\n\nRE: ...'; + } + } + + // Renoteのとき + if (note.renoteId) { + if (note.renote) { + summary += `\n\nRN: ${getNoteSummary(note.renote)}`; + } else { + summary += '\n\nRN: ...'; + } + } + + return summary.trim(); +}; diff --git a/packages/frontend/src/scripts/get-static-image-url.ts b/packages/frontend/src/scripts/get-static-image-url.ts new file mode 100644 index 0000000000..cbd1761983 --- /dev/null +++ b/packages/frontend/src/scripts/get-static-image-url.ts @@ -0,0 +1,19 @@ +import { url as instanceUrl } from '@/config'; +import * as url from '@/scripts/url'; + +export function getStaticImageUrl(baseUrl: string): string { + const u = new URL(baseUrl); + if (u.href.startsWith(`${instanceUrl}/proxy/`)) { + // もう既にproxyっぽそうだったらsearchParams付けるだけ + u.searchParams.set('static', '1'); + return u.href; + } + + // 拡張子がないとキャッシュしてくれないCDNがあるのでダミーの名前を指定する + const dummy = `${encodeURIComponent(`${u.host}${u.pathname}`)}.webp`; + + return `${instanceUrl}/proxy/${dummy}?${url.query({ + url: u.href, + static: '1', + })}`; +} diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts new file mode 100644 index 0000000000..2faacffdfc --- /dev/null +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -0,0 +1,253 @@ +import * as Acct from 'misskey-js/built/acct'; +import { defineAsyncComponent } from 'vue'; +import { i18n } from '@/i18n'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { host } from '@/config'; +import * as os from '@/os'; +import { userActions } from '@/store'; +import { $i, iAmModerator } from '@/account'; +import { mainRouter } from '@/router'; +import { Router } from '@/nirax'; + +export function getUserMenu(user, router: Router = mainRouter) { + const meId = $i ? $i.id : null; + + async function pushList() { + const t = i18n.ts.selectList; // なぜか後で参照すると null になるので最初にメモリに確保しておく + const lists = await os.api('users/lists/list'); + if (lists.length === 0) { + os.alert({ + type: 'error', + text: i18n.ts.youHaveNoLists, + }); + return; + } + const { canceled, result: listId } = await os.select({ + title: t, + items: lists.map(list => ({ + value: list.id, text: list.name, + })), + }); + if (canceled) return; + os.apiWithDialog('users/lists/push', { + listId: listId, + userId: user.id, + }); + } + + async function inviteGroup() { + const groups = await os.api('users/groups/owned'); + if (groups.length === 0) { + os.alert({ + type: 'error', + text: i18n.ts.youHaveNoGroups, + }); + return; + } + const { canceled, result: groupId } = await os.select({ + title: i18n.ts.group, + items: groups.map(group => ({ + value: group.id, text: group.name, + })), + }); + if (canceled) return; + os.apiWithDialog('users/groups/invite', { + groupId: groupId, + userId: user.id, + }); + } + + 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 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 toggleSilence() { + if (!await getConfirmed(i18n.t(user.isSilenced ? 'unsilenceConfirm' : 'silenceConfirm'))) return; + + os.apiWithDialog(user.isSilenced ? 'admin/unsilence-user' : 'admin/silence-user', { + userId: user.id, + }).then(() => { + user.isSilenced = !user.isSilenced; + }); + } + + async function toggleSuspend() { + if (!await getConfirmed(i18n.t(user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return; + + os.apiWithDialog(user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', { + userId: user.id, + }).then(() => { + user.isSuspended = !user.isSuspended; + }); + } + + function reportAbuse() { + os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: user, + }, {}, 'closed'); + } + + async function getConfirmed(text: string): Promise { + const confirm = await os.confirm({ + type: 'warning', + title: 'confirm', + text, + }); + + return !confirm.canceled; + } + + async function invalidateFollow() { + os.apiWithDialog('following/invalidate', { + userId: user.id, + }).then(() => { + user.isFollowed = !user.isFollowed; + }); + } + + let menu = [{ + icon: 'ti ti-at', + text: i18n.ts.copyUsername, + action: () => { + copyToClipboard(`@${user.username}@${user.host || host}`); + }, + }, { + icon: 'ti ti-rss', + text: i18n.ts.copyRSS, + action: () => { + copyToClipboard(`${user.host || host}/@${user.username}.atom`); + } + }, { + icon: 'ti ti-info-circle', + text: i18n.ts.info, + action: () => { + router.push(`/user-info/${user.id}`); + }, + }, { + icon: 'ti ti-mail', + text: i18n.ts.sendMessage, + action: () => { + os.post({ specified: user }); + }, + }, meId !== user.id ? { + type: 'link', + icon: 'ti ti-messages', + text: i18n.ts.startMessaging, + to: '/my/messaging/' + Acct.toString(user), + } : undefined, null, { + icon: 'ti ti-list', + text: i18n.ts.addToList, + action: pushList, + }, meId !== user.id ? { + icon: 'ti ti-users', + text: i18n.ts.inviteToGroup, + action: inviteGroup, + } : undefined] as any; + + if ($i && meId !== user.id) { + menu = menu.concat([null, { + icon: user.isMuted ? 'ti ti-eye' : 'ti ti-eye-off', + text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute, + action: toggleMute, + }, { + icon: 'ti ti-ban', + text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block, + action: toggleBlock, + }]); + + if (user.isFollowed) { + menu = menu.concat([{ + icon: 'ti ti-link-off', + text: i18n.ts.breakFollow, + action: invalidateFollow, + }]); + } + + menu = menu.concat([null, { + icon: 'ti ti-exclamation-circle', + text: i18n.ts.reportAbuse, + action: reportAbuse, + }]); + + if (iAmModerator) { + menu = menu.concat([null, { + icon: 'ti ti-microphone-2-off', + text: user.isSilenced ? i18n.ts.unsilence : i18n.ts.silence, + action: toggleSilence, + }, { + icon: 'ti ti-snowflake', + text: user.isSuspended ? i18n.ts.unsuspend : i18n.ts.suspend, + action: toggleSuspend, + }]); + } + } + + if ($i && meId === user.id) { + menu = menu.concat([null, { + icon: 'ti ti-pencil', + text: i18n.ts.editProfile, + action: () => { + router.push('/settings/profile'); + }, + }]); + } + + if (userActions.length > 0) { + menu = menu.concat([null, ...userActions.map(action => ({ + icon: 'ti ti-plug', + text: action.title, + action: () => { + action.handler(user); + }, + }))]); + } + + return menu; +} diff --git a/packages/frontend/src/scripts/get-user-name.ts b/packages/frontend/src/scripts/get-user-name.ts new file mode 100644 index 0000000000..d499ea0203 --- /dev/null +++ b/packages/frontend/src/scripts/get-user-name.ts @@ -0,0 +1,3 @@ +export default function(user: { name?: string | null, username: string }): string { + return user.name || user.username; +} diff --git a/packages/frontend/src/scripts/hotkey.ts b/packages/frontend/src/scripts/hotkey.ts new file mode 100644 index 0000000000..4a0ded637d --- /dev/null +++ b/packages/frontend/src/scripts/hotkey.ts @@ -0,0 +1,90 @@ +import keyCode from './keycode'; + +type Callback = (ev: KeyboardEvent) => void; + +type Keymap = Record; + +type Pattern = { + which: string[]; + ctrl?: boolean; + shift?: boolean; + alt?: boolean; +}; + +type Action = { + patterns: Pattern[]; + callback: Callback; + allowRepeat: boolean; +}; + +const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, callback]): Action => { + const result = { + patterns: [], + callback, + allowRepeat: true, + } as Action; + + if (patterns.match(/^\(.*\)$/) !== null) { + result.allowRepeat = false; + patterns = patterns.slice(1, -1); + } + + result.patterns = patterns.split('|').map(part => { + const pattern = { + which: [], + ctrl: false, + alt: false, + shift: false, + } as Pattern; + + const keys = part.trim().split('+').map(x => x.trim().toLowerCase()); + for (const key of keys) { + switch (key) { + case 'ctrl': pattern.ctrl = true; break; + case 'alt': pattern.alt = true; break; + case 'shift': pattern.shift = true; break; + default: pattern.which = keyCode(key).map(k => k.toLowerCase()); + } + } + + return pattern; + }); + + return result; +}); + +const ignoreElemens = ['input', 'textarea']; + +function match(ev: KeyboardEvent, patterns: Action['patterns']): boolean { + const key = ev.code.toLowerCase(); + return patterns.some(pattern => pattern.which.includes(key) && + pattern.ctrl === ev.ctrlKey && + pattern.shift === ev.shiftKey && + pattern.alt === ev.altKey && + !ev.metaKey, + ); +} + +export const makeHotkey = (keymap: Keymap) => { + const actions = parseKeymap(keymap); + + return (ev: KeyboardEvent) => { + if (document.activeElement) { + if (ignoreElemens.some(el => document.activeElement!.matches(el))) return; + if (document.activeElement.attributes['contenteditable']) return; + } + + for (const action of actions) { + const matched = match(ev, action.patterns); + + if (matched) { + if (!action.allowRepeat && ev.repeat) return; + + ev.preventDefault(); + ev.stopPropagation(); + action.callback(ev); + break; + } + } + }; +}; diff --git a/packages/frontend/src/scripts/hpml/block.ts b/packages/frontend/src/scripts/hpml/block.ts new file mode 100644 index 0000000000..804c5c1124 --- /dev/null +++ b/packages/frontend/src/scripts/hpml/block.ts @@ -0,0 +1,109 @@ +// blocks + +export type BlockBase = { + id: string; + type: string; +}; + +export type TextBlock = BlockBase & { + type: 'text'; + text: string; +}; + +export type SectionBlock = BlockBase & { + type: 'section'; + title: string; + children: (Block | VarBlock)[]; +}; + +export type ImageBlock = BlockBase & { + type: 'image'; + fileId: string | null; +}; + +export type ButtonBlock = BlockBase & { + type: 'button'; + text: any; + primary: boolean; + action: string; + content: string; + event: string; + message: string; + var: string; + fn: string; +}; + +export type IfBlock = BlockBase & { + type: 'if'; + var: string; + children: Block[]; +}; + +export type TextareaBlock = BlockBase & { + type: 'textarea'; + text: string; +}; + +export type PostBlock = BlockBase & { + type: 'post'; + text: string; + attachCanvasImage: boolean; + canvasId: string; +}; + +export type CanvasBlock = BlockBase & { + type: 'canvas'; + name: string; // canvas id + width: number; + height: number; +}; + +export type NoteBlock = BlockBase & { + type: 'note'; + detailed: boolean; + note: string | null; +}; + +export type Block = + TextBlock | SectionBlock | ImageBlock | ButtonBlock | IfBlock | TextareaBlock | PostBlock | CanvasBlock | NoteBlock | VarBlock; + +// variable blocks + +export type VarBlockBase = BlockBase & { + name: string; +}; + +export type NumberInputVarBlock = VarBlockBase & { + type: 'numberInput'; + text: string; +}; + +export type TextInputVarBlock = VarBlockBase & { + type: 'textInput'; + text: string; +}; + +export type SwitchVarBlock = VarBlockBase & { + type: 'switch'; + text: string; +}; + +export type RadioButtonVarBlock = VarBlockBase & { + type: 'radioButton'; + title: string; + values: string[]; +}; + +export type CounterVarBlock = VarBlockBase & { + type: 'counter'; + text: string; + inc: number; +}; + +export type VarBlock = + NumberInputVarBlock | TextInputVarBlock | SwitchVarBlock | RadioButtonVarBlock | CounterVarBlock; + +const varBlock = ['numberInput', 'textInput', 'switch', 'radioButton', 'counter']; +export function isVarBlock(block: Block): block is VarBlock { + return varBlock.includes(block.type); +} diff --git a/packages/frontend/src/scripts/hpml/evaluator.ts b/packages/frontend/src/scripts/hpml/evaluator.ts new file mode 100644 index 0000000000..196b3142a1 --- /dev/null +++ b/packages/frontend/src/scripts/hpml/evaluator.ts @@ -0,0 +1,232 @@ +import autobind from 'autobind-decorator'; +import { PageVar, envVarsDef, Fn, HpmlScope, HpmlError } from '.'; +import { version } from '@/config'; +import { AiScript, utils, values } from '@syuilo/aiscript'; +import { createAiScriptEnv } from '../aiscript/api'; +import { collectPageVars } from '../collect-page-vars'; +import { initHpmlLib, initAiLib } from './lib'; +import * as os from '@/os'; +import { markRaw, ref, Ref, unref } from 'vue'; +import { Expr, isLiteralValue, Variable } from './expr'; + +/** + * Hpml evaluator + */ +export class Hpml { + private variables: Variable[]; + private pageVars: PageVar[]; + private envVars: Record; + public aiscript?: AiScript; + public pageVarUpdatedCallback?: values.VFn; + public canvases: Record = {}; + public vars: Ref> = ref({}); + public page: Record; + + private opts: { + randomSeed: string; visitor?: any; url?: string; + enableAiScript: boolean; + }; + + constructor(page: Hpml['page'], opts: Hpml['opts']) { + this.page = page; + this.variables = this.page.variables; + this.pageVars = collectPageVars(this.page.content); + this.opts = opts; + + if (this.opts.enableAiScript) { + this.aiscript = markRaw(new AiScript({ ...createAiScriptEnv({ + storageKey: 'pages:' + this.page.id, + }), ...initAiLib(this) }, { + in: (q) => { + return new Promise(ok => { + os.inputText({ + title: q, + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + console.log(value); + }, + log: (type, params) => { + }, + })); + + this.aiscript.scope.opts.onUpdated = (name, value) => { + this.eval(); + }; + } + + const date = new Date(); + + this.envVars = { + AI: 'kawaii', + VERSION: version, + URL: this.page ? `${opts.url}/@${this.page.user.username}/pages/${this.page.name}` : '', + LOGIN: opts.visitor != null, + NAME: opts.visitor ? opts.visitor.name || opts.visitor.username : '', + USERNAME: opts.visitor ? opts.visitor.username : '', + USERID: opts.visitor ? opts.visitor.id : '', + NOTES_COUNT: opts.visitor ? opts.visitor.notesCount : 0, + FOLLOWERS_COUNT: opts.visitor ? opts.visitor.followersCount : 0, + FOLLOWING_COUNT: opts.visitor ? opts.visitor.followingCount : 0, + IS_CAT: opts.visitor ? opts.visitor.isCat : false, + SEED: opts.randomSeed ? opts.randomSeed : '', + YMD: `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`, + AISCRIPT_DISABLED: !this.opts.enableAiScript, + NULL: null, + }; + + this.eval(); + } + + @autobind + public eval() { + try { + this.vars.value = this.evaluateVars(); + } catch (err) { + //this.onError(e); + } + } + + @autobind + public interpolate(str: string) { + if (str == null) return null; + return str.replace(/{(.+?)}/g, match => { + const v = unref(this.vars)[match.slice(1, -1).trim()]; + return v == null ? 'NULL' : v.toString(); + }); + } + + @autobind + public callAiScript(fn: string) { + try { + if (this.aiscript) this.aiscript.execFn(this.aiscript.scope.get(fn), []); + } catch (err) {} + } + + @autobind + public registerCanvas(id: string, canvas: any) { + this.canvases[id] = canvas; + } + + @autobind + public updatePageVar(name: string, value: any) { + const pageVar = this.pageVars.find(v => v.name === name); + if (pageVar !== undefined) { + pageVar.value = value; + if (this.pageVarUpdatedCallback) { + if (this.aiscript) this.aiscript.execFn(this.pageVarUpdatedCallback, [values.STR(name), utils.jsToVal(value)]); + } + } else { + throw new HpmlError(`No such page var '${name}'`); + } + } + + @autobind + public updateRandomSeed(seed: string) { + this.opts.randomSeed = seed; + this.envVars.SEED = seed; + } + + @autobind + private _interpolateScope(str: string, scope: HpmlScope) { + return str.replace(/{(.+?)}/g, match => { + const v = scope.getState(match.slice(1, -1).trim()); + return v == null ? 'NULL' : v.toString(); + }); + } + + @autobind + public evaluateVars(): Record { + const values: Record = {}; + + for (const [k, v] of Object.entries(this.envVars)) { + values[k] = v; + } + + for (const v of this.pageVars) { + values[v.name] = v.value; + } + + for (const v of this.variables) { + values[v.name] = this.evaluate(v, new HpmlScope([values])); + } + + return values; + } + + @autobind + private evaluate(expr: Expr, scope: HpmlScope): any { + if (isLiteralValue(expr)) { + if (expr.type === null) { + return null; + } + + if (expr.type === 'number') { + return parseInt((expr.value as any), 10); + } + + if (expr.type === 'text' || expr.type === 'multiLineText') { + return this._interpolateScope(expr.value || '', scope); + } + + if (expr.type === 'textList') { + return this._interpolateScope(expr.value || '', scope).trim().split('\n'); + } + + if (expr.type === 'ref') { + return scope.getState(expr.value); + } + + if (expr.type === 'aiScriptVar') { + if (this.aiscript) { + try { + return utils.valToJs(this.aiscript.scope.get(expr.value)); + } catch (err) { + return null; + } + } else { + return null; + } + } + + // Define user function + if (expr.type === 'fn') { + return { + slots: expr.value.slots.map(x => x.name), + exec: (slotArg: Record) => { + return this.evaluate(expr.value.expression, scope.createChildScope(slotArg, expr.id)); + }, + } as Fn; + } + return; + } + + // Call user function + if (expr.type.startsWith('fn:')) { + const fnName = expr.type.split(':')[1]; + const fn = scope.getState(fnName); + const args = {} as Record; + for (let i = 0; i < fn.slots.length; i++) { + const name = fn.slots[i]; + args[name] = this.evaluate(expr.args[i], scope); + } + return fn.exec(args); + } + + if (expr.args === undefined) return null; + + const funcs = initHpmlLib(expr, scope, this.opts.randomSeed, this.opts.visitor); + + // Call function + const fnName = expr.type; + const fn = (funcs as any)[fnName]; + if (fn == null) { + throw new HpmlError(`No such function '${fnName}'`); + } else { + return fn(...expr.args.map(x => this.evaluate(x, scope))); + } + } +} diff --git a/packages/frontend/src/scripts/hpml/expr.ts b/packages/frontend/src/scripts/hpml/expr.ts new file mode 100644 index 0000000000..18c7c2a14b --- /dev/null +++ b/packages/frontend/src/scripts/hpml/expr.ts @@ -0,0 +1,79 @@ +import { literalDefs, Type } from '.'; + +export type ExprBase = { + id: string; +}; + +// value + +export type EmptyValue = ExprBase & { + type: null; + value: null; +}; + +export type TextValue = ExprBase & { + type: 'text'; + value: string; +}; + +export type MultiLineTextValue = ExprBase & { + type: 'multiLineText'; + value: string; +}; + +export type TextListValue = ExprBase & { + type: 'textList'; + value: string; +}; + +export type NumberValue = ExprBase & { + type: 'number'; + value: number; +}; + +export type RefValue = ExprBase & { + type: 'ref'; + value: string; // value is variable name +}; + +export type AiScriptRefValue = ExprBase & { + type: 'aiScriptVar'; + value: string; // value is variable name +}; + +export type UserFnValue = ExprBase & { + type: 'fn'; + value: UserFnInnerValue; +}; +type UserFnInnerValue = { + slots: { + name: string; + type: Type; + }[]; + expression: Expr; +}; + +export type Value = + EmptyValue | TextValue | MultiLineTextValue | TextListValue | NumberValue | RefValue | AiScriptRefValue | UserFnValue; + +export function isLiteralValue(expr: Expr): expr is Value { + if (expr.type == null) return true; + if (literalDefs[expr.type]) return true; + return false; +} + +// call function + +export type CallFn = ExprBase & { // "fn:hoge" or string + type: string; + args: Expr[]; + value: null; +}; + +// variable +export type Variable = (Value | CallFn) & { + name: string; +}; + +// expression +export type Expr = Variable | Value | CallFn; diff --git a/packages/frontend/src/scripts/hpml/index.ts b/packages/frontend/src/scripts/hpml/index.ts new file mode 100644 index 0000000000..9a55a5c286 --- /dev/null +++ b/packages/frontend/src/scripts/hpml/index.ts @@ -0,0 +1,103 @@ +/** + * Hpml + */ + +import autobind from 'autobind-decorator'; +import { Hpml } from './evaluator'; +import { funcDefs } from './lib'; + +export type Fn = { + slots: string[]; + exec: (args: Record) => ReturnType; +}; + +export type Type = 'string' | 'number' | 'boolean' | 'stringArray' | null; + +export const literalDefs: Record = { + text: { out: 'string', category: 'value', icon: 'ti ti-quote' }, + multiLineText: { out: 'string', category: 'value', icon: 'fas fa-align-left' }, + textList: { out: 'stringArray', category: 'value', icon: 'fas fa-list' }, + number: { out: 'number', category: 'value', icon: 'fas fa-sort-numeric-up' }, + ref: { out: null, category: 'value', icon: 'fas fa-magic' }, + aiScriptVar: { out: null, category: 'value', icon: 'fas fa-magic' }, + fn: { out: 'function', category: 'value', icon: 'fas fa-square-root-alt' }, +}; + +export const blockDefs = [ + ...Object.entries(literalDefs).map(([k, v]) => ({ + type: k, out: v.out, category: v.category, icon: v.icon, + })), + ...Object.entries(funcDefs).map(([k, v]) => ({ + type: k, out: v.out, category: v.category, icon: v.icon, + })), +]; + +export type PageVar = { name: string; value: any; type: Type; }; + +export const envVarsDef: Record = { + AI: 'string', + URL: 'string', + VERSION: 'string', + LOGIN: 'boolean', + NAME: 'string', + USERNAME: 'string', + USERID: 'string', + NOTES_COUNT: 'number', + FOLLOWERS_COUNT: 'number', + FOLLOWING_COUNT: 'number', + IS_CAT: 'boolean', + SEED: null, + YMD: 'string', + AISCRIPT_DISABLED: 'boolean', + NULL: null, +}; + +export class HpmlScope { + private layerdStates: Record[]; + public name: string; + + constructor(layerdStates: HpmlScope['layerdStates'], name?: HpmlScope['name']) { + this.layerdStates = layerdStates; + this.name = name || 'anonymous'; + } + + @autobind + public createChildScope(states: Record, name?: HpmlScope['name']): HpmlScope { + const layer = [states, ...this.layerdStates]; + return new HpmlScope(layer, name); + } + + /** + * 指定した名前の変数の値を取得します + * @param name 変数名 + */ + @autobind + public getState(name: string): any { + for (const later of this.layerdStates) { + const state = later[name]; + if (state !== undefined) { + return state; + } + } + + throw new HpmlError( + `No such variable '${name}' in scope '${this.name}'`, { + scope: this.layerdStates, + }); + } +} + +export class HpmlError extends Error { + public info?: any; + + constructor(message: string, info?: any) { + super(message); + + this.info = info; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, HpmlError); + } + } +} diff --git a/packages/frontend/src/scripts/hpml/lib.ts b/packages/frontend/src/scripts/hpml/lib.ts new file mode 100644 index 0000000000..b684876a7f --- /dev/null +++ b/packages/frontend/src/scripts/hpml/lib.ts @@ -0,0 +1,247 @@ +import tinycolor from 'tinycolor2'; +import { Hpml } from './evaluator'; +import { values, utils } from '@syuilo/aiscript'; +import { Fn, HpmlScope } from '.'; +import { Expr } from './expr'; +import seedrandom from 'seedrandom'; + +/* TODO: https://www.chartjs.org/docs/latest/configuration/canvas-background.html#color +// https://stackoverflow.com/questions/38493564/chart-area-background-color-chartjs +Chart.pluginService.register({ + beforeDraw: (chart, easing) => { + if (chart.config.options.chartArea && chart.config.options.chartArea.backgroundColor) { + const ctx = chart.chart.ctx; + ctx.save(); + ctx.fillStyle = chart.config.options.chartArea.backgroundColor; + ctx.fillRect(0, 0, chart.chart.width, chart.chart.height); + ctx.restore(); + } + } +}); +*/ + +export function initAiLib(hpml: Hpml) { + return { + 'MkPages:updated': values.FN_NATIVE(([callback]) => { + hpml.pageVarUpdatedCallback = (callback as values.VFn); + }), + 'MkPages:get_canvas': values.FN_NATIVE(([id]) => { + utils.assertString(id); + const canvas = hpml.canvases[id.value]; + const ctx = canvas.getContext('2d'); + return values.OBJ(new Map([ + ['clear_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.clearRect(x.value, y.value, width.value, height.value); })], + ['fill_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.fillRect(x.value, y.value, width.value, height.value); })], + ['stroke_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.strokeRect(x.value, y.value, width.value, height.value); })], + ['fill_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.fillText(text.value, x.value, y.value, width ? width.value : undefined); })], + ['stroke_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.strokeText(text.value, x.value, y.value, width ? width.value : undefined); })], + ['set_line_width', values.FN_NATIVE(([width]) => { ctx.lineWidth = width.value; })], + ['set_font', values.FN_NATIVE(([font]) => { ctx.font = font.value; })], + ['set_fill_style', values.FN_NATIVE(([style]) => { ctx.fillStyle = style.value; })], + ['set_stroke_style', values.FN_NATIVE(([style]) => { ctx.strokeStyle = style.value; })], + ['begin_path', values.FN_NATIVE(() => { ctx.beginPath(); })], + ['close_path', values.FN_NATIVE(() => { ctx.closePath(); })], + ['move_to', values.FN_NATIVE(([x, y]) => { ctx.moveTo(x.value, y.value); })], + ['line_to', values.FN_NATIVE(([x, y]) => { ctx.lineTo(x.value, y.value); })], + ['arc', values.FN_NATIVE(([x, y, radius, startAngle, endAngle]) => { ctx.arc(x.value, y.value, radius.value, startAngle.value, endAngle.value); })], + ['rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.rect(x.value, y.value, width.value, height.value); })], + ['fill', values.FN_NATIVE(() => { ctx.fill(); })], + ['stroke', values.FN_NATIVE(() => { ctx.stroke(); })], + ])); + }), + 'MkPages:chart': values.FN_NATIVE(([id, opts]) => { + /* TODO + utils.assertString(id); + utils.assertObject(opts); + const canvas = hpml.canvases[id.value]; + const color = getComputedStyle(document.documentElement).getPropertyValue('--accent'); + Chart.defaults.color = '#555'; + const chart = new Chart(canvas, { + type: opts.value.get('type').value, + data: { + labels: opts.value.get('labels').value.map(x => x.value), + datasets: opts.value.get('datasets').value.map(x => ({ + label: x.value.has('label') ? x.value.get('label').value : '', + data: x.value.get('data').value.map(x => x.value), + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: x.value.has('color') ? x.value.get('color') : color, + backgroundColor: tinycolor(x.value.has('color') ? x.value.get('color') : color).setAlpha(0.1).toRgbString(), + })) + }, + options: { + responsive: false, + devicePixelRatio: 1.5, + title: { + display: opts.value.has('title'), + text: opts.value.has('title') ? opts.value.get('title').value : '', + fontSize: 14, + }, + layout: { + padding: { + left: 32, + right: 32, + top: opts.value.has('title') ? 16 : 32, + bottom: 16 + } + }, + legend: { + display: opts.value.get('datasets').value.filter(x => x.value.has('label') && x.value.get('label').value).length === 0 ? false : true, + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + tooltips: { + enabled: false, + }, + chartArea: { + backgroundColor: '#fff' + }, + ...(opts.value.get('type').value === 'radar' ? { + scale: { + ticks: { + display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : false, + min: opts.value.has('min') ? opts.value.get('min').value : undefined, + max: opts.value.has('max') ? opts.value.get('max').value : undefined, + maxTicksLimit: 8, + }, + pointLabels: { + fontSize: 12 + } + } + } : { + scales: { + yAxes: [{ + ticks: { + display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : true, + min: opts.value.has('min') ? opts.value.get('min').value : undefined, + max: opts.value.has('max') ? opts.value.get('max').value : undefined, + } + }] + } + }) + } + }); + */ + }), + }; +} + +export const funcDefs: Record = { + if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: 'ti ti-share' }, + for: { in: ['number', 'function'], out: null, category: 'flow', icon: 'fas fa-recycle' }, + not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-plus' }, + subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-minus' }, + multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-x' }, + divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' }, + mod: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' }, + round: { in: ['number'], out: 'number', category: 'operation', icon: 'fas fa-calculator' }, + eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-equals' }, + notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-not-equal' }, + gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than' }, + lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than' }, + gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than-equal' }, + ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than-equal' }, + strLen: { in: ['string'], out: 'number', category: 'text', icon: 'ti ti-quote' }, + strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'ti ti-quote' }, + strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' }, + strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'ti ti-quote' }, + join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' }, + stringToNumber: { in: ['string'], out: 'number', category: 'convert', icon: 'fas fa-exchange-alt' }, + numberToString: { in: ['number'], out: 'string', category: 'convert', icon: 'fas fa-exchange-alt' }, + splitStrByLine: { in: ['string'], out: 'stringArray', category: 'convert', icon: 'fas fa-exchange-alt' }, + pick: { in: [null, 'number'], out: null, category: 'list', icon: 'fas fa-indent' }, + listLen: { in: [null], out: 'number', category: 'list', icon: 'fas fa-indent' }, + rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + seedRannum: { in: [null, 'number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + random: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + seedRandom: { in: [null, 'number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + randomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' }, + dailyRandomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' }, + seedRandomPick: { in: [null, 0], out: 0, category: 'random', icon: 'fas fa-dice' }, + DRPWPM: { in: ['stringArray'], out: 'string', category: 'random', icon: 'fas fa-dice' }, // dailyRandomPickWithProbabilityMapping +}; + +export function initHpmlLib(expr: Expr, scope: HpmlScope, randomSeed: string, visitor?: any) { + const date = new Date(); + const day = `${visitor ? visitor.id : ''} ${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; + + // SHOULD be fine to ignore since it's intended + function shape isn't defined + // eslint-disable-next-line @typescript-eslint/ban-types + const funcs: Record = { + not: (a: boolean) => !a, + or: (a: boolean, b: boolean) => a || b, + and: (a: boolean, b: boolean) => a && b, + eq: (a: any, b: any) => a === b, + notEq: (a: any, b: any) => a !== b, + gt: (a: number, b: number) => a > b, + lt: (a: number, b: number) => a < b, + gtEq: (a: number, b: number) => a >= b, + ltEq: (a: number, b: number) => a <= b, + if: (bool: boolean, a: any, b: any) => bool ? a : b, + for: (times: number, fn: Fn) => { + const result: any[] = []; + for (let i = 0; i < times; i++) { + result.push(fn.exec({ + [fn.slots[0]]: i + 1, + })); + } + return result; + }, + add: (a: number, b: number) => a + b, + subtract: (a: number, b: number) => a - b, + multiply: (a: number, b: number) => a * b, + divide: (a: number, b: number) => a / b, + mod: (a: number, b: number) => a % b, + round: (a: number) => Math.round(a), + strLen: (a: string) => a.length, + strPick: (a: string, b: number) => a[b - 1], + strReplace: (a: string, b: string, c: string) => a.split(b).join(c), + strReverse: (a: string) => a.split('').reverse().join(''), + join: (texts: string[], separator: string) => texts.join(separator || ''), + stringToNumber: (a: string) => parseInt(a), + numberToString: (a: number) => a.toString(), + splitStrByLine: (a: string) => a.split('\n'), + pick: (list: any[], i: number) => list[i - 1], + listLen: (list: any[]) => list.length, + random: (probability: number) => Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * 100) < probability, + rannum: (min: number, max: number) => min + Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * (max - min + 1)), + randomPick: (list: any[]) => list[Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * list.length)], + dailyRandom: (probability: number) => Math.floor(seedrandom(`${day}:${expr.id}`)() * 100) < probability, + dailyRannum: (min: number, max: number) => min + Math.floor(seedrandom(`${day}:${expr.id}`)() * (max - min + 1)), + dailyRandomPick: (list: any[]) => list[Math.floor(seedrandom(`${day}:${expr.id}`)() * list.length)], + seedRandom: (seed: any, probability: number) => Math.floor(seedrandom(seed)() * 100) < probability, + seedRannum: (seed: any, min: number, max: number) => min + Math.floor(seedrandom(seed)() * (max - min + 1)), + seedRandomPick: (seed: any, list: any[]) => list[Math.floor(seedrandom(seed)() * list.length)], + DRPWPM: (list: string[]) => { + const xs: any[] = []; + let totalFactor = 0; + for (const x of list) { + const parts = x.split(' '); + const factor = parseInt(parts.pop()!, 10); + const text = parts.join(' '); + totalFactor += factor; + xs.push({ factor, text }); + } + const r = seedrandom(`${day}:${expr.id}`)() * totalFactor; + let stackedFactor = 0; + for (const x of xs) { + if (r >= stackedFactor && r <= stackedFactor + x.factor) { + return x.text; + } else { + stackedFactor += x.factor; + } + } + return xs[0].text; + }, + }; + + return funcs; +} diff --git a/packages/frontend/src/scripts/hpml/type-checker.ts b/packages/frontend/src/scripts/hpml/type-checker.ts new file mode 100644 index 0000000000..24c9ed8bcb --- /dev/null +++ b/packages/frontend/src/scripts/hpml/type-checker.ts @@ -0,0 +1,191 @@ +import autobind from 'autobind-decorator'; +import { isLiteralValue } from './expr'; +import { funcDefs } from './lib'; +import { envVarsDef } from '.'; +import type { Type, PageVar } from '.'; +import type { Expr, Variable } from './expr'; + +type TypeError = { + arg: number; + expect: Type; + actual: Type; +}; + +/** + * Hpml type checker + */ +export class HpmlTypeChecker { + public variables: Variable[]; + public pageVars: PageVar[]; + + constructor(variables: HpmlTypeChecker['variables'] = [], pageVars: HpmlTypeChecker['pageVars'] = []) { + this.variables = variables; + this.pageVars = pageVars; + } + + @autobind + public typeCheck(v: Expr): TypeError | null { + if (isLiteralValue(v)) return null; + + const def = funcDefs[v.type || '']; + if (def == null) { + throw new Error('Unknown type: ' + v.type); + } + + const generic: Type[] = []; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + const type = this.infer(v.args[i]); + if (type === null) continue; + + if (typeof arg === 'number') { + if (generic[arg] === undefined) { + generic[arg] = type; + } else if (type !== generic[arg]) { + return { + arg: i, + expect: generic[arg], + actual: type, + }; + } + } else if (type !== arg) { + return { + arg: i, + expect: arg, + actual: type, + }; + } + } + + return null; + } + + @autobind + public getExpectedType(v: Expr, slot: number): Type { + const def = funcDefs[v.type || '']; + if (def == null) { + throw new Error('Unknown type: ' + v.type); + } + + const generic: Type[] = []; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + const type = this.infer(v.args[i]); + if (type === null) continue; + + if (typeof arg === 'number') { + if (generic[arg] === undefined) { + generic[arg] = type; + } + } + } + + if (typeof def.in[slot] === 'number') { + return generic[def.in[slot]] ?? null; + } else { + return def.in[slot]; + } + } + + @autobind + public infer(v: Expr): Type { + if (v.type === null) return null; + if (v.type === 'text') return 'string'; + if (v.type === 'multiLineText') return 'string'; + if (v.type === 'textList') return 'stringArray'; + if (v.type === 'number') return 'number'; + if (v.type === 'ref') { + const variable = this.variables.find(va => va.name === v.value); + if (variable) { + return this.infer(variable); + } + + const pageVar = this.pageVars.find(va => va.name === v.value); + if (pageVar) { + return pageVar.type; + } + + const envVar = envVarsDef[v.value || '']; + if (envVar !== undefined) { + return envVar; + } + + return null; + } + if (v.type === 'aiScriptVar') return null; + if (v.type === 'fn') return null; // todo + if (v.type.startsWith('fn:')) return null; // todo + + const generic: Type[] = []; + + const def = funcDefs[v.type]; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + if (typeof arg === 'number') { + const type = this.infer(v.args[i]); + + if (generic[arg] === undefined) { + generic[arg] = type; + } else { + if (type !== generic[arg]) { + generic[arg] = null; + } + } + } + } + + if (typeof def.out === 'number') { + return generic[def.out]; + } else { + return def.out; + } + } + + @autobind + public getVarByName(name: string): Variable { + const v = this.variables.find(x => x.name === name); + if (v !== undefined) { + return v; + } else { + throw new Error(`No such variable '${name}'`); + } + } + + @autobind + public getVarsByType(type: Type): Variable[] { + if (type == null) return this.variables; + return this.variables.filter(x => (this.infer(x) === null) || (this.infer(x) === type)); + } + + @autobind + public getEnvVarsByType(type: Type): string[] { + if (type == null) return Object.keys(envVarsDef); + return Object.entries(envVarsDef).filter(([k, v]) => v === null || type === v).map(([k, v]) => k); + } + + @autobind + public getPageVarsByType(type: Type): string[] { + if (type == null) return this.pageVars.map(v => v.name); + return this.pageVars.filter(v => type === v.type).map(v => v.name); + } + + @autobind + public isUsedName(name: string) { + if (this.variables.some(v => v.name === name)) { + return true; + } + + if (this.pageVars.some(v => v.name === name)) { + return true; + } + + if (envVarsDef[name]) { + return true; + } + + return false; + } +} diff --git a/packages/frontend/src/scripts/i18n.ts b/packages/frontend/src/scripts/i18n.ts new file mode 100644 index 0000000000..54184386da --- /dev/null +++ b/packages/frontend/src/scripts/i18n.ts @@ -0,0 +1,29 @@ +export class I18n> { + public ts: T; + + constructor(locale: T) { + this.ts = locale; + + //#region BIND + this.t = this.t.bind(this); + //#endregion + } + + // string にしているのは、ドット区切りでのパス指定を許可するため + // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも + public t(key: string, args?: Record): string { + try { + let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string; + + if (args) { + for (const [k, v] of Object.entries(args)) { + str = str.replace(`{${k}}`, v.toString()); + } + } + return str; + } catch (err) { + console.warn(`missing localization '${key}'`); + return key; + } + } +} diff --git a/packages/frontend/src/scripts/idb-proxy.ts b/packages/frontend/src/scripts/idb-proxy.ts new file mode 100644 index 0000000000..77bb84463c --- /dev/null +++ b/packages/frontend/src/scripts/idb-proxy.ts @@ -0,0 +1,36 @@ +// FirefoxのプライベートモードなどではindexedDBが使用不可能なので、 +// indexedDBが使えない環境ではlocalStorageを使う +import { + get as iget, + set as iset, + del as idel, +} from 'idb-keyval'; + +const fallbackName = (key: string) => `idbfallback::${key}`; + +let idbAvailable = typeof window !== 'undefined' ? !!window.indexedDB : true; + +if (idbAvailable) { + 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 JSON.parse(localStorage.getItem(fallbackName(key))); +} + +export async function set(key: string, val: any) { + if (idbAvailable) return iset(key, val); + return localStorage.setItem(fallbackName(key), JSON.stringify(val)); +} + +export async function del(key: string) { + if (idbAvailable) return idel(key); + return localStorage.removeItem(fallbackName(key)); +} diff --git a/packages/frontend/src/scripts/initialize-sw.ts b/packages/frontend/src/scripts/initialize-sw.ts new file mode 100644 index 0000000000..de52f30523 --- /dev/null +++ b/packages/frontend/src/scripts/initialize-sw.ts @@ -0,0 +1,13 @@ +import { lang } from '@/config'; + +export async function initializeSw() { + if (!('serviceWorker' in navigator)) return; + + navigator.serviceWorker.register(`/sw.js`, { scope: '/', type: 'classic' }); + navigator.serviceWorker.ready.then(registration => { + registration.active?.postMessage({ + msg: 'initialize', + lang, + }); + }); +} diff --git a/packages/frontend/src/scripts/is-device-darkmode.ts b/packages/frontend/src/scripts/is-device-darkmode.ts new file mode 100644 index 0000000000..854f38e517 --- /dev/null +++ b/packages/frontend/src/scripts/is-device-darkmode.ts @@ -0,0 +1,3 @@ +export function isDeviceDarkmode() { + return window.matchMedia('(prefers-color-scheme: dark)').matches; +} diff --git a/packages/frontend/src/scripts/keycode.ts b/packages/frontend/src/scripts/keycode.ts new file mode 100644 index 0000000000..69f6a82803 --- /dev/null +++ b/packages/frontend/src/scripts/keycode.ts @@ -0,0 +1,33 @@ +export default (input: string): string[] => { + if (Object.keys(aliases).some(a => a.toLowerCase() === input.toLowerCase())) { + const codes = aliases[input]; + return Array.isArray(codes) ? codes : [codes]; + } else { + return [input]; + } +}; + +export const aliases = { + 'esc': 'Escape', + 'enter': ['Enter', 'NumpadEnter'], + 'up': 'ArrowUp', + 'down': 'ArrowDown', + 'left': 'ArrowLeft', + 'right': 'ArrowRight', + 'plus': ['NumpadAdd', 'Semicolon'], +}; + +/*! +* Programmatically add the following +*/ + +// lower case chars +for (let i = 97; i < 123; i++) { + const char = String.fromCharCode(i); + aliases[char] = `Key${char.toUpperCase()}`; +} + +// numbers +for (let i = 0; i < 10; i++) { + aliases[i] = [`Numpad${i}`, `Digit${i}`]; +} diff --git a/packages/frontend/src/scripts/langmap.ts b/packages/frontend/src/scripts/langmap.ts new file mode 100644 index 0000000000..25f5b366c8 --- /dev/null +++ b/packages/frontend/src/scripts/langmap.ts @@ -0,0 +1,666 @@ +// TODO: sharedに置いてバックエンドのと統合したい +export const langmap = { + 'ach': { + nativeName: 'Lwo', + }, + 'ady': { + nativeName: 'Адыгэбзэ', + }, + 'af': { + nativeName: 'Afrikaans', + }, + 'af-NA': { + nativeName: 'Afrikaans (Namibia)', + }, + 'af-ZA': { + nativeName: 'Afrikaans (South Africa)', + }, + 'ak': { + nativeName: 'Tɕɥi', + }, + 'ar': { + nativeName: 'العربية', + }, + 'ar-AR': { + nativeName: 'العربية', + }, + 'ar-MA': { + nativeName: 'العربية', + }, + 'ar-SA': { + nativeName: 'العربية (السعودية)', + }, + 'ay-BO': { + nativeName: 'Aymar aru', + }, + 'az': { + nativeName: 'Azərbaycan dili', + }, + 'az-AZ': { + nativeName: 'Azərbaycan dili', + }, + 'be-BY': { + nativeName: 'Беларуская', + }, + 'bg': { + nativeName: 'Български', + }, + 'bg-BG': { + nativeName: 'Български', + }, + 'bn': { + nativeName: 'বাংলা', + }, + 'bn-IN': { + nativeName: 'বাংলা (ভারত)', + }, + 'bn-BD': { + nativeName: 'বাংলা(বাংলাদেশ)', + }, + 'br': { + nativeName: 'Brezhoneg', + }, + 'bs-BA': { + nativeName: 'Bosanski', + }, + 'ca': { + nativeName: 'Català', + }, + 'ca-ES': { + nativeName: 'Català', + }, + 'cak': { + nativeName: 'Maya Kaqchikel', + }, + 'ck-US': { + nativeName: 'ᏣᎳᎩ (tsalagi)', + }, + 'cs': { + nativeName: 'Čeština', + }, + 'cs-CZ': { + nativeName: 'Čeština', + }, + 'cy': { + nativeName: 'Cymraeg', + }, + 'cy-GB': { + nativeName: 'Cymraeg', + }, + 'da': { + nativeName: 'Dansk', + }, + 'da-DK': { + nativeName: 'Dansk', + }, + 'de': { + nativeName: 'Deutsch', + }, + 'de-AT': { + nativeName: 'Deutsch (Österreich)', + }, + 'de-DE': { + nativeName: 'Deutsch (Deutschland)', + }, + 'de-CH': { + nativeName: 'Deutsch (Schweiz)', + }, + 'dsb': { + nativeName: 'Dolnoserbšćina', + }, + 'el': { + nativeName: 'Ελληνικά', + }, + 'el-GR': { + nativeName: 'Ελληνικά', + }, + 'en': { + nativeName: 'English', + }, + 'en-GB': { + nativeName: 'English (UK)', + }, + 'en-AU': { + nativeName: 'English (Australia)', + }, + 'en-CA': { + nativeName: 'English (Canada)', + }, + 'en-IE': { + nativeName: 'English (Ireland)', + }, + 'en-IN': { + nativeName: 'English (India)', + }, + 'en-PI': { + nativeName: 'English (Pirate)', + }, + 'en-SG': { + nativeName: 'English (Singapore)', + }, + 'en-UD': { + nativeName: 'English (Upside Down)', + }, + 'en-US': { + nativeName: 'English (US)', + }, + 'en-ZA': { + nativeName: 'English (South Africa)', + }, + 'en@pirate': { + nativeName: 'English (Pirate)', + }, + 'eo': { + nativeName: 'Esperanto', + }, + 'eo-EO': { + nativeName: 'Esperanto', + }, + 'es': { + nativeName: 'Español', + }, + 'es-AR': { + nativeName: 'Español (Argentine)', + }, + 'es-419': { + nativeName: 'Español (Latinoamérica)', + }, + 'es-CL': { + nativeName: 'Español (Chile)', + }, + 'es-CO': { + nativeName: 'Español (Colombia)', + }, + 'es-EC': { + nativeName: 'Español (Ecuador)', + }, + 'es-ES': { + nativeName: 'Español (España)', + }, + 'es-LA': { + nativeName: 'Español (Latinoamérica)', + }, + 'es-NI': { + nativeName: 'Español (Nicaragua)', + }, + 'es-MX': { + nativeName: 'Español (México)', + }, + 'es-US': { + nativeName: 'Español (Estados Unidos)', + }, + 'es-VE': { + nativeName: 'Español (Venezuela)', + }, + 'et': { + nativeName: 'eesti keel', + }, + 'et-EE': { + nativeName: 'Eesti (Estonia)', + }, + 'eu': { + nativeName: 'Euskara', + }, + 'eu-ES': { + nativeName: 'Euskara', + }, + 'fa': { + nativeName: 'فارسی', + }, + 'fa-IR': { + nativeName: 'فارسی', + }, + 'fb-LT': { + nativeName: 'Leet Speak', + }, + 'ff': { + nativeName: 'Fulah', + }, + 'fi': { + nativeName: 'Suomi', + }, + 'fi-FI': { + nativeName: 'Suomi', + }, + 'fo': { + nativeName: 'Føroyskt', + }, + 'fo-FO': { + nativeName: 'Føroyskt (Færeyjar)', + }, + 'fr': { + nativeName: 'Français', + }, + 'fr-CA': { + nativeName: 'Français (Canada)', + }, + 'fr-FR': { + nativeName: 'Français (France)', + }, + 'fr-BE': { + nativeName: 'Français (Belgique)', + }, + 'fr-CH': { + nativeName: 'Français (Suisse)', + }, + 'fy-NL': { + nativeName: 'Frysk', + }, + 'ga': { + nativeName: 'Gaeilge', + }, + 'ga-IE': { + nativeName: 'Gaeilge', + }, + 'gd': { + nativeName: 'Gàidhlig', + }, + 'gl': { + nativeName: 'Galego', + }, + 'gl-ES': { + nativeName: 'Galego', + }, + 'gn-PY': { + nativeName: 'Avañe\'ẽ', + }, + 'gu-IN': { + nativeName: 'ગુજરાતી', + }, + 'gv': { + nativeName: 'Gaelg', + }, + 'gx-GR': { + nativeName: 'Ἑλληνική ἀρχαία', + }, + 'he': { + nativeName: 'עברית‏', + }, + 'he-IL': { + nativeName: 'עברית‏', + }, + 'hi': { + nativeName: 'हिन्दी', + }, + 'hi-IN': { + nativeName: 'हिन्दी', + }, + 'hr': { + nativeName: 'Hrvatski', + }, + 'hr-HR': { + nativeName: 'Hrvatski', + }, + 'hsb': { + nativeName: 'Hornjoserbšćina', + }, + 'ht': { + nativeName: 'Kreyòl', + }, + 'hu': { + nativeName: 'Magyar', + }, + 'hu-HU': { + nativeName: 'Magyar', + }, + 'hy': { + nativeName: 'Հայերեն', + }, + 'hy-AM': { + nativeName: 'Հայերեն (Հայաստան)', + }, + 'id': { + nativeName: 'Bahasa Indonesia', + }, + 'id-ID': { + nativeName: 'Bahasa Indonesia', + }, + 'is': { + nativeName: 'Íslenska', + }, + 'is-IS': { + nativeName: 'Íslenska (Iceland)', + }, + 'it': { + nativeName: 'Italiano', + }, + 'it-IT': { + nativeName: 'Italiano', + }, + 'ja': { + nativeName: '日本語', + }, + 'ja-JP': { + nativeName: '日本語 (日本)', + }, + 'jv-ID': { + nativeName: 'Basa Jawa', + }, + 'ka-GE': { + nativeName: 'ქართული', + }, + 'kk-KZ': { + nativeName: 'Қазақша', + }, + 'km': { + nativeName: 'ភាសាខ្មែរ', + }, + 'kl': { + nativeName: 'kalaallisut', + }, + 'km-KH': { + nativeName: 'ភាសាខ្មែរ', + }, + 'kab': { + nativeName: 'Taqbaylit', + }, + 'kn': { + nativeName: 'ಕನ್ನಡ', + }, + 'kn-IN': { + nativeName: 'ಕನ್ನಡ (India)', + }, + 'ko': { + nativeName: '한국어', + }, + 'ko-KR': { + nativeName: '한국어 (한국)', + }, + 'ku-TR': { + nativeName: 'Kurdî', + }, + 'kw': { + nativeName: 'Kernewek', + }, + 'la': { + nativeName: 'Latin', + }, + 'la-VA': { + nativeName: 'Latin', + }, + 'lb': { + nativeName: 'Lëtzebuergesch', + }, + 'li-NL': { + nativeName: 'Lèmbörgs', + }, + 'lt': { + nativeName: 'Lietuvių', + }, + 'lt-LT': { + nativeName: 'Lietuvių', + }, + 'lv': { + nativeName: 'Latviešu', + }, + 'lv-LV': { + nativeName: 'Latviešu', + }, + 'mai': { + nativeName: 'मैथिली, মৈথিলী', + }, + 'mg-MG': { + nativeName: 'Malagasy', + }, + 'mk': { + nativeName: 'Македонски', + }, + 'mk-MK': { + nativeName: 'Македонски (Македонски)', + }, + 'ml': { + nativeName: 'മലയാളം', + }, + 'ml-IN': { + nativeName: 'മലയാളം', + }, + 'mn-MN': { + nativeName: 'Монгол', + }, + 'mr': { + nativeName: 'मराठी', + }, + 'mr-IN': { + nativeName: 'मराठी', + }, + 'ms': { + nativeName: 'Bahasa Melayu', + }, + 'ms-MY': { + nativeName: 'Bahasa Melayu', + }, + 'mt': { + nativeName: 'Malti', + }, + 'mt-MT': { + nativeName: 'Malti', + }, + 'my': { + nativeName: 'ဗမာစကာ', + }, + 'no': { + nativeName: 'Norsk', + }, + 'nb': { + nativeName: 'Norsk (bokmål)', + }, + 'nb-NO': { + nativeName: 'Norsk (bokmål)', + }, + 'ne': { + nativeName: 'नेपाली', + }, + 'ne-NP': { + nativeName: 'नेपाली', + }, + 'nl': { + nativeName: 'Nederlands', + }, + 'nl-BE': { + nativeName: 'Nederlands (België)', + }, + 'nl-NL': { + nativeName: 'Nederlands (Nederland)', + }, + 'nn-NO': { + nativeName: 'Norsk (nynorsk)', + }, + 'oc': { + nativeName: 'Occitan', + }, + 'or-IN': { + nativeName: 'ଓଡ଼ିଆ', + }, + 'pa': { + nativeName: 'ਪੰਜਾਬੀ', + }, + 'pa-IN': { + nativeName: 'ਪੰਜਾਬੀ (ਭਾਰਤ ਨੂੰ)', + }, + 'pl': { + nativeName: 'Polski', + }, + 'pl-PL': { + nativeName: 'Polski', + }, + 'ps-AF': { + nativeName: 'پښتو', + }, + 'pt': { + nativeName: 'Português', + }, + 'pt-BR': { + nativeName: 'Português (Brasil)', + }, + 'pt-PT': { + nativeName: 'Português (Portugal)', + }, + 'qu-PE': { + nativeName: 'Qhichwa', + }, + 'rm-CH': { + nativeName: 'Rumantsch', + }, + 'ro': { + nativeName: 'Română', + }, + 'ro-RO': { + nativeName: 'Română', + }, + 'ru': { + nativeName: 'Русский', + }, + 'ru-RU': { + nativeName: 'Русский', + }, + 'sa-IN': { + nativeName: 'संस्कृतम्', + }, + 'se-NO': { + nativeName: 'Davvisámegiella', + }, + 'sh': { + nativeName: 'српскохрватски', + }, + 'si-LK': { + nativeName: 'සිංහල', + }, + 'sk': { + nativeName: 'Slovenčina', + }, + 'sk-SK': { + nativeName: 'Slovenčina (Slovakia)', + }, + 'sl': { + nativeName: 'Slovenščina', + }, + 'sl-SI': { + nativeName: 'Slovenščina', + }, + 'so-SO': { + nativeName: 'Soomaaliga', + }, + 'sq': { + nativeName: 'Shqip', + }, + 'sq-AL': { + nativeName: 'Shqip', + }, + 'sr': { + nativeName: 'Српски', + }, + 'sr-RS': { + nativeName: 'Српски (Serbia)', + }, + 'su': { + nativeName: 'Basa Sunda', + }, + 'sv': { + nativeName: 'Svenska', + }, + 'sv-SE': { + nativeName: 'Svenska', + }, + 'sw': { + nativeName: 'Kiswahili', + }, + 'sw-KE': { + nativeName: 'Kiswahili', + }, + 'ta': { + nativeName: 'தமிழ்', + }, + 'ta-IN': { + nativeName: 'தமிழ்', + }, + 'te': { + nativeName: 'తెలుగు', + }, + 'te-IN': { + nativeName: 'తెలుగు', + }, + 'tg': { + nativeName: 'забо́ни тоҷикӣ́', + }, + 'tg-TJ': { + nativeName: 'тоҷикӣ', + }, + 'th': { + nativeName: 'ภาษาไทย', + }, + 'th-TH': { + nativeName: 'ภาษาไทย (ประเทศไทย)', + }, + 'fil': { + nativeName: 'Filipino', + }, + 'tlh': { + nativeName: 'tlhIngan-Hol', + }, + 'tr': { + nativeName: 'Türkçe', + }, + 'tr-TR': { + nativeName: 'Türkçe', + }, + 'tt-RU': { + nativeName: 'татарча', + }, + 'uk': { + nativeName: 'Українська', + }, + 'uk-UA': { + nativeName: 'Українська', + }, + 'ur': { + nativeName: 'اردو', + }, + 'ur-PK': { + nativeName: 'اردو', + }, + 'uz': { + nativeName: 'O\'zbek', + }, + 'uz-UZ': { + nativeName: 'O\'zbek', + }, + 'vi': { + nativeName: 'Tiếng Việt', + }, + 'vi-VN': { + nativeName: 'Tiếng Việt', + }, + 'xh-ZA': { + nativeName: 'isiXhosa', + }, + 'yi': { + nativeName: 'ייִדיש', + }, + 'yi-DE': { + nativeName: 'ייִדיש (German)', + }, + 'zh': { + nativeName: '中文', + }, + 'zh-Hans': { + nativeName: '中文简体', + }, + 'zh-Hant': { + nativeName: '中文繁體', + }, + 'zh-CN': { + nativeName: '中文(中国大陆)', + }, + 'zh-HK': { + nativeName: '中文(香港)', + }, + 'zh-SG': { + nativeName: '中文(新加坡)', + }, + 'zh-TW': { + nativeName: '中文(台灣)', + }, + 'zu-ZA': { + nativeName: 'isiZulu', + }, +}; diff --git a/packages/frontend/src/scripts/login-id.ts b/packages/frontend/src/scripts/login-id.ts new file mode 100644 index 0000000000..0f9c6be4a9 --- /dev/null +++ b/packages/frontend/src/scripts/login-id.ts @@ -0,0 +1,11 @@ +export function getUrlWithLoginId(url: string, loginId: string) { + const u = new URL(url, origin); + u.searchParams.append('loginId', loginId); + return u.toString(); +} + +export function getUrlWithoutLoginId(url: string) { + const u = new URL(url); + u.searchParams.delete('loginId'); + return u.toString(); +} diff --git a/packages/frontend/src/scripts/lookup-user.ts b/packages/frontend/src/scripts/lookup-user.ts new file mode 100644 index 0000000000..3ab9d55300 --- /dev/null +++ b/packages/frontend/src/scripts/lookup-user.ts @@ -0,0 +1,36 @@ +import * as Acct from 'misskey-js/built/acct'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; + +export async function lookupUser() { + const { canceled, result } = await os.inputText({ + title: i18n.ts.usernameOrUserId, + }); + if (canceled) return; + + const show = (user) => { + os.pageWindow(`/user-info/${user.id}`); + }; + + const usernamePromise = os.api('users/show', Acct.parse(result)); + const idPromise = os.api('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(); + }); +} diff --git a/packages/frontend/src/scripts/media-proxy.ts b/packages/frontend/src/scripts/media-proxy.ts new file mode 100644 index 0000000000..aaf7f9e610 --- /dev/null +++ b/packages/frontend/src/scripts/media-proxy.ts @@ -0,0 +1,15 @@ +import { query } from '@/scripts/url'; +import { url } from '@/config'; + +export function getProxiedImageUrl(imageUrl: string, type?: 'preview'): string { + return `${url}/proxy/image.webp?${query({ + url: imageUrl, + fallback: '1', + ...(type ? { [type]: '1' } : {}), + })}`; +} + +export function getProxiedImageUrlNullable(imageUrl: string | null | undefined, type?: 'preview'): string | null { + if (imageUrl == null) return null; + return getProxiedImageUrl(imageUrl, type); +} diff --git a/packages/frontend/src/scripts/mfm-tags.ts b/packages/frontend/src/scripts/mfm-tags.ts new file mode 100644 index 0000000000..18e8d7038a --- /dev/null +++ b/packages/frontend/src/scripts/mfm-tags.ts @@ -0,0 +1 @@ +export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'font', 'blur', 'rainbow', 'sparkle', 'rotate']; diff --git a/packages/frontend/src/scripts/page-metadata.ts b/packages/frontend/src/scripts/page-metadata.ts new file mode 100644 index 0000000000..0db8369f9d --- /dev/null +++ b/packages/frontend/src/scripts/page-metadata.ts @@ -0,0 +1,41 @@ +import * as misskey from 'misskey-js'; +import { ComputedRef, inject, isRef, onActivated, onMounted, provide, ref, Ref } from 'vue'; + +export const setPageMetadata = Symbol('setPageMetadata'); +export const pageMetadataProvider = Symbol('pageMetadataProvider'); + +export type PageMetadata = { + title: string; + subtitle?: string; + icon?: string | null; + avatar?: misskey.entities.User | null; + userName?: misskey.entities.User | null; + bg?: string; +}; + +export function definePageMetadata(metadata: PageMetadata | null | Ref | ComputedRef): void { + const _metadata = isRef(metadata) ? metadata : ref(metadata); + + provide(pageMetadataProvider, _metadata); + + const set = inject(setPageMetadata) as any; + if (set) { + set(_metadata); + + onMounted(() => { + set(_metadata); + }); + + onActivated(() => { + set(_metadata); + }); + } +} + +export function provideMetadataReceiver(callback: (info: ComputedRef) => void): void { + provide(setPageMetadata, callback); +} + +export function injectPageMetadata(): PageMetadata | undefined { + return inject(pageMetadataProvider); +} diff --git a/packages/frontend/src/scripts/physics.ts b/packages/frontend/src/scripts/physics.ts new file mode 100644 index 0000000000..efda80f074 --- /dev/null +++ b/packages/frontend/src/scripts/physics.ts @@ -0,0 +1,152 @@ +import * as Matter from 'matter-js'; + +export function physics(container: HTMLElement) { + const containerWidth = container.offsetWidth; + const containerHeight = container.offsetHeight; + const containerCenterX = containerWidth / 2; + + // サイズ固定化(要らないかも?) + container.style.position = 'relative'; + container.style.boxSizing = 'border-box'; + container.style.width = `${containerWidth}px`; + container.style.height = `${containerHeight}px`; + + // create engine + const engine = Matter.Engine.create({ + constraintIterations: 4, + positionIterations: 8, + velocityIterations: 8, + }); + + const world = engine.world; + + // create renderer + const render = Matter.Render.create({ + engine: engine, + //element: document.getElementById('debug'), + options: { + width: containerWidth, + height: containerHeight, + background: 'transparent', // transparent to hide + wireframeBackground: 'transparent', // transparent to hide + }, + }); + + // Disable to hide debug + Matter.Render.run(render); + + // create runner + const runner = Matter.Runner.create(); + Matter.Runner.run(runner, engine); + + const groundThickness = 1024; + const ground = Matter.Bodies.rectangle(containerCenterX, containerHeight + (groundThickness / 2), containerWidth, groundThickness, { + isStatic: true, + restitution: 0.1, + friction: 2, + }); + + //const wallRight = Matter.Bodies.rectangle(window.innerWidth+50, window.innerHeight/2, 100, window.innerHeight, wallopts); + //const wallLeft = Matter.Bodies.rectangle(-50, window.innerHeight/2, 100, window.innerHeight, wallopts); + + Matter.World.add(world, [ + ground, + //wallRight, + //wallLeft, + ]); + + const objEls = Array.from(container.children) as HTMLElement[]; + const objs: Matter.Body[] = []; + for (const objEl of objEls) { + const left = objEl.dataset.physicsX ? parseInt(objEl.dataset.physicsX) : objEl.offsetLeft; + const top = objEl.dataset.physicsY ? parseInt(objEl.dataset.physicsY) : objEl.offsetTop; + + let obj: Matter.Body; + if (objEl.classList.contains('_physics_circle_')) { + obj = Matter.Bodies.circle( + left + (objEl.offsetWidth / 2), + top + (objEl.offsetHeight / 2), + Math.max(objEl.offsetWidth, objEl.offsetHeight) / 2, + { + restitution: 0.5, + }, + ); + } else { + const style = window.getComputedStyle(objEl); + obj = Matter.Bodies.rectangle( + left + (objEl.offsetWidth / 2), + top + (objEl.offsetHeight / 2), + objEl.offsetWidth, + objEl.offsetHeight, + { + chamfer: { radius: parseInt(style.borderRadius || '0', 10) }, + restitution: 0.5, + }, + ); + } + objEl.id = obj.id.toString(); + objs.push(obj); + } + + Matter.World.add(engine.world, objs); + + // Add mouse control + + const mouse = Matter.Mouse.create(container); + const mouseConstraint = Matter.MouseConstraint.create(engine, { + mouse: mouse, + constraint: { + stiffness: 0.1, + render: { + visible: false, + }, + }, + }); + + Matter.World.add(engine.world, mouseConstraint); + + // keep the mouse in sync with rendering + render.mouse = mouse; + + for (const objEl of objEls) { + objEl.style.position = 'absolute'; + objEl.style.top = '0'; + objEl.style.left = '0'; + objEl.style.margin = '0'; + } + + window.requestAnimationFrame(update); + + let stop = false; + + function update() { + for (const objEl of objEls) { + const obj = objs.find(obj => obj.id.toString() === objEl.id.toString()); + if (obj == null) continue; + + const x = (obj.position.x - objEl.offsetWidth / 2); + const y = (obj.position.y - objEl.offsetHeight / 2); + const angle = obj.angle; + objEl.style.transform = `translate(${x}px, ${y}px) rotate(${angle}rad)`; + } + + if (!stop) { + window.requestAnimationFrame(update); + } + } + + // 奈落に落ちたオブジェクトは消す + const intervalId = window.setInterval(() => { + for (const obj of objs) { + if (obj.position.y > (containerHeight + 1024)) Matter.World.remove(world, obj); + } + }, 1000 * 10); + + return { + stop: () => { + stop = true; + Matter.Runner.stop(runner); + window.clearInterval(intervalId); + }, + }; +} diff --git a/packages/frontend/src/scripts/please-login.ts b/packages/frontend/src/scripts/please-login.ts new file mode 100644 index 0000000000..b8fb853cc1 --- /dev/null +++ b/packages/frontend/src/scripts/please-login.ts @@ -0,0 +1,21 @@ +import { defineAsyncComponent } from 'vue'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { popup } from '@/os'; + +export function pleaseLogin(path?: string) { + if ($i) return; + + popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), { + autoSet: true, + message: i18n.ts.signinRequired, + }, { + cancelled: () => { + if (path) { + window.location.href = path; + } + }, + }, 'closed'); + + if (!path) throw new Error('signin required'); +} diff --git a/packages/frontend/src/scripts/popout.ts b/packages/frontend/src/scripts/popout.ts new file mode 100644 index 0000000000..580031d0a3 --- /dev/null +++ b/packages/frontend/src/scripts/popout.ts @@ -0,0 +1,23 @@ +import * as config from '@/config'; +import { appendQuery } from './url'; + +export function popout(path: string, w?: HTMLElement) { + let url = path.startsWith('http://') || path.startsWith('https://') ? path : config.url + path; + url = appendQuery(url, 'zen'); + if (w) { + const position = w.getBoundingClientRect(); + const width = parseInt(getComputedStyle(w, '').width, 10); + const height = parseInt(getComputedStyle(w, '').height, 10); + const x = window.screenX + position.left; + const y = window.screenY + position.top; + window.open(url, url, + `width=${width}, height=${height}, top=${y}, left=${x}`); + } else { + const width = 400; + const height = 500; + const x = window.top.outerHeight / 2 + window.top.screenY - (height / 2); + const y = window.top.outerWidth / 2 + window.top.screenX - (width / 2); + window.open(url, url, + `width=${width}, height=${height}, top=${x}, left=${y}`); + } +} diff --git a/packages/frontend/src/scripts/popup-position.ts b/packages/frontend/src/scripts/popup-position.ts new file mode 100644 index 0000000000..e84eebf103 --- /dev/null +++ b/packages/frontend/src/scripts/popup-position.ts @@ -0,0 +1,158 @@ +import { Ref } from 'vue'; + +export function calcPopupPosition(el: HTMLElement, props: { + anchorElement: HTMLElement | null; + innerMargin: number; + direction: 'top' | 'bottom' | 'left' | 'right'; + align: 'top' | 'bottom' | 'left' | 'right' | 'center'; + alignOffset?: number; + x?: number; + y?: number; +}): { top: number; left: number; transformOrigin: string; } { + const contentWidth = el.offsetWidth; + const contentHeight = el.offsetHeight; + + let rect: DOMRect; + + if (props.anchorElement) { + rect = props.anchorElement.getBoundingClientRect(); + } + + const calcPosWhenTop = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2); + top = (rect.top + window.pageYOffset - contentHeight) - props.innerMargin; + } else { + left = props.x; + top = (props.y - contentHeight) - props.innerMargin; + } + + left -= (el.offsetWidth / 2); + + if (left + contentWidth - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - contentWidth + window.pageXOffset - 1; + } + + return [left, top]; + }; + + const calcPosWhenBottom = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = rect.left + window.pageXOffset + (props.anchorElement.offsetWidth / 2); + top = (rect.top + window.pageYOffset + props.anchorElement.offsetHeight) + props.innerMargin; + } else { + left = props.x; + top = (props.y) + props.innerMargin; + } + + left -= (el.offsetWidth / 2); + + if (left + contentWidth - window.pageXOffset > window.innerWidth) { + left = window.innerWidth - contentWidth + window.pageXOffset - 1; + } + + return [left, top]; + }; + + const calcPosWhenLeft = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = (rect.left + window.pageXOffset - contentWidth) - props.innerMargin; + top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2); + } else { + left = (props.x - contentWidth) - props.innerMargin; + top = props.y; + } + + top -= (el.offsetHeight / 2); + + if (top + contentHeight - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - contentHeight + window.pageYOffset - 1; + } + + return [left, top]; + }; + + const calcPosWhenRight = () => { + let left: number; + let top: number; + + if (props.anchorElement) { + left = (rect.left + props.anchorElement.offsetWidth + window.pageXOffset) + props.innerMargin; + + if (props.align === 'top') { + top = rect.top + window.pageYOffset; + if (props.alignOffset != null) top += props.alignOffset; + } else if (props.align === 'bottom') { + // TODO + } else { // center + top = rect.top + window.pageYOffset + (props.anchorElement.offsetHeight / 2); + top -= (el.offsetHeight / 2); + } + } else { + left = props.x + props.innerMargin; + top = props.y; + top -= (el.offsetHeight / 2); + } + + if (top + contentHeight - window.pageYOffset > window.innerHeight) { + top = window.innerHeight - contentHeight + window.pageYOffset - 1; + } + + return [left, top]; + }; + + const calc = (): { + left: number; + top: number; + transformOrigin: string; + } => { + switch (props.direction) { + case 'top': { + const [left, top] = calcPosWhenTop(); + + // ツールチップを上に向かって表示するスペースがなければ下に向かって出す + if (top - window.pageYOffset < 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.pageXOffset < 0) { + const [left, top] = calcPosWhenRight(); + return { left, top, transformOrigin: 'left center' }; + } + + return { left, top, transformOrigin: 'right center' }; + } + + case 'right': { + const [left, top] = calcPosWhenRight(); + // TODO: ツールチップを右に向かって表示するスペースがなければ左に向かって出す + return { left, top, transformOrigin: 'left center' }; + } + } + }; + + return calc(); +} diff --git a/packages/frontend/src/scripts/reaction-picker.ts b/packages/frontend/src/scripts/reaction-picker.ts new file mode 100644 index 0000000000..fe32e719da --- /dev/null +++ b/packages/frontend/src/scripts/reaction-picker.ts @@ -0,0 +1,41 @@ +import { defineAsyncComponent, Ref, ref } from 'vue'; +import { popup } from '@/os'; + +class ReactionPicker { + private src: Ref = ref(null); + private manualShowing = ref(false); + private onChosen?: (reaction: string) => void; + private onClosed?: () => void; + + constructor() { + // nop + } + + public async init() { + await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { + src: this.src, + asReactionPicker: true, + manualShowing: this.manualShowing, + }, { + done: reaction => { + this.onChosen!(reaction); + }, + close: () => { + this.manualShowing.value = false; + }, + closed: () => { + this.src.value = null; + this.onClosed!(); + }, + }); + } + + public show(src: HTMLElement, onChosen: ReactionPicker['onChosen'], onClosed: ReactionPicker['onClosed']) { + this.src.value = src; + this.manualShowing.value = true; + this.onChosen = onChosen; + this.onClosed = onClosed; + } +} + +export const reactionPicker = new ReactionPicker(); diff --git a/packages/frontend/src/scripts/safe-uri-decode.ts b/packages/frontend/src/scripts/safe-uri-decode.ts new file mode 100644 index 0000000000..301b56d7fd --- /dev/null +++ b/packages/frontend/src/scripts/safe-uri-decode.ts @@ -0,0 +1,7 @@ +export function safeURIDecode(str: string): string { + try { + return decodeURIComponent(str); + } catch { + return str; + } +} diff --git a/packages/frontend/src/scripts/scroll.ts b/packages/frontend/src/scripts/scroll.ts new file mode 100644 index 0000000000..f5bc6bf9ce --- /dev/null +++ b/packages/frontend/src/scripts/scroll.ts @@ -0,0 +1,85 @@ +type ScrollBehavior = 'auto' | 'smooth' | 'instant'; + +export function getScrollContainer(el: HTMLElement | null): HTMLElement | null { + if (el == null || el.tagName === 'HTML') return null; + const overflow = window.getComputedStyle(el).getPropertyValue('overflow-y'); + if (overflow === 'scroll' || overflow === 'auto') { + return el; + } else { + return getScrollContainer(el.parentElement); + } +} + +export function getScrollPosition(el: Element | null): number { + const container = getScrollContainer(el); + return container == null ? window.scrollY : container.scrollTop; +} + +export function isTopVisible(el: Element | null): boolean { + const scrollTop = getScrollPosition(el); + const topPosition = el.offsetTop; // TODO: container内でのelの相対位置を取得できればより正確になる + + return scrollTop <= topPosition; +} + +export function isBottomVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) { + if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + tolerance; + return el.scrollHeight <= window.innerHeight + window.scrollY + tolerance; +} + +export function onScrollTop(el: Element, cb) { + const container = getScrollContainer(el) || window; + const onScroll = ev => { + if (!document.body.contains(el)) return; + if (isTopVisible(el)) { + cb(); + container.removeEventListener('scroll', onScroll); + } + }; + container.addEventListener('scroll', onScroll, { passive: true }); +} + +export function onScrollBottom(el: Element, cb) { + const container = getScrollContainer(el) || window; + const onScroll = ev => { + if (!document.body.contains(el)) return; + const pos = getScrollPosition(el); + if (pos + el.clientHeight > el.scrollHeight - 1) { + cb(); + container.removeEventListener('scroll', onScroll); + } + }; + container.addEventListener('scroll', onScroll, { passive: true }); +} + +export function scroll(el: Element, options: { + top?: number; + left?: number; + behavior?: ScrollBehavior; +}) { + const container = getScrollContainer(el); + if (container == null) { + window.scroll(options); + } else { + container.scroll(options); + } +} + +export function scrollToTop(el: Element, options: { behavior?: ScrollBehavior; } = {}) { + scroll(el, { top: 0, ...options }); +} + +export function scrollToBottom(el: Element, options: { behavior?: ScrollBehavior; } = {}) { + scroll(el, { top: 99999, ...options }); // TODO: ちゃんと計算する +} + +export function isBottom(el: Element, asobi = 0) { + const container = getScrollContainer(el); + const current = container + ? el.scrollTop + el.offsetHeight + : window.scrollY + window.innerHeight; + const max = container + ? el.scrollHeight + : document.body.offsetHeight; + return current >= (max - asobi); +} diff --git a/packages/frontend/src/scripts/search.ts b/packages/frontend/src/scripts/search.ts new file mode 100644 index 0000000000..64914d3d65 --- /dev/null +++ b/packages/frontend/src/scripts/search.ts @@ -0,0 +1,63 @@ +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { mainRouter } from '@/router'; + +export async function search() { + const { canceled, result: query } = await os.inputText({ + title: i18n.ts.search, + }); + if (canceled || query == null || query === '') return; + + const q = query.trim(); + + if (q.startsWith('@') && !q.includes(' ')) { + mainRouter.push(`/${q}`); + return; + } + + if (q.startsWith('#')) { + mainRouter.push(`/tags/${encodeURIComponent(q.substr(1))}`); + return; + } + + // like 2018/03/12 + if (/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}/.test(q.replace(/-/g, '/'))) { + const date = new Date(q.replace(/-/g, '/')); + + // 日付しか指定されてない場合、例えば 2018/03/12 ならユーザーは + // 2018/03/12 のコンテンツを「含む」結果になることを期待するはずなので + // 23時間59分進める(そのままだと 2018/03/12 00:00:00 「まで」の + // 結果になってしまい、2018/03/12 のコンテンツは含まれない) + if (q.replace(/-/g, '/').match(/^[0-9]{4}\/[0-9]{2}\/[0-9]{2}$/)) { + date.setHours(23, 59, 59, 999); + } + + // TODO + //v.$root.$emit('warp', date); + os.alert({ + icon: 'fas fa-history', + iconOnly: true, autoClose: true, + }); + return; + } + + if (q.startsWith('https://')) { + const promise = os.api('ap/show', { + uri: q, + }); + + os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); + + const res = await promise; + + if (res.type === 'User') { + mainRouter.push(`/@${res.object.username}@${res.object.host}`); + } else if (res.type === 'Note') { + mainRouter.push(`/notes/${res.object.id}`); + } + + return; + } + + mainRouter.push(`/search?q=${encodeURIComponent(q)}`); +} diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts new file mode 100644 index 0000000000..ec5f8f65e9 --- /dev/null +++ b/packages/frontend/src/scripts/select-file.ts @@ -0,0 +1,103 @@ +import { ref } from 'vue'; +import { DriveFile } from 'misskey-js/built/entities'; +import * as os from '@/os'; +import { stream } from '@/stream'; +import { i18n } from '@/i18n'; +import { defaultStore } from '@/store'; +import { uploadFile } from '@/scripts/upload'; + +function select(src: any, label: string | null, multiple: boolean): Promise { + return new Promise((res, rej) => { + const keepOriginal = ref(defaultStore.state.keepOriginalUploading); + + const chooseFileFromPc = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = multiple; + input.onchange = () => { + const promises = Array.from(input.files).map(file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal.value)); + + Promise.all(promises).then(driveFiles => { + res(multiple ? driveFiles : driveFiles[0]); + }).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(); + }; + + const chooseFileFromDrive = () => { + os.selectDriveFile(multiple).then(files => { + res(files); + }); + }; + + const chooseFileFromUrl = () => { + 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 = stream.useChannel('main'); + connection.on('urlUploadFinished', urlResponse => { + if (urlResponse.marker === marker) { + res(multiple ? [urlResponse.file] : urlResponse.file); + connection.dispose(); + } + }); + + os.api('drive/files/upload-from-url', { + url: url, + folderId: defaultStore.state.uploadFolder, + marker, + }); + + os.alert({ + title: i18n.ts.uploadFromUrlRequested, + text: i18n.ts.uploadFromUrlMayTakeTime, + }); + }); + }; + + 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, + }, { + text: i18n.ts.fromDrive, + icon: 'ti ti-cloud', + action: chooseFileFromDrive, + }, { + text: i18n.ts.fromUrl, + icon: 'ti ti-link', + action: chooseFileFromUrl, + }], src); + }); +} + +export function selectFile(src: any, label: string | null = null): Promise { + return select(src, label, false) as Promise; +} + +export function selectFiles(src: any, label: string | null = null): Promise { + return select(src, label, true) as Promise; +} diff --git a/packages/frontend/src/scripts/show-suspended-dialog.ts b/packages/frontend/src/scripts/show-suspended-dialog.ts new file mode 100644 index 0000000000..e11569ecd4 --- /dev/null +++ b/packages/frontend/src/scripts/show-suspended-dialog.ts @@ -0,0 +1,10 @@ +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +export function showSuspendedDialog() { + return os.alert({ + type: 'error', + title: i18n.ts.yourAccountSuspendedTitle, + text: i18n.ts.yourAccountSuspendedDescription, + }); +} diff --git a/packages/frontend/src/scripts/shuffle.ts b/packages/frontend/src/scripts/shuffle.ts new file mode 100644 index 0000000000..05e6cdfbcf --- /dev/null +++ b/packages/frontend/src/scripts/shuffle.ts @@ -0,0 +1,19 @@ +/** + * 配列をシャッフル (破壊的) + */ +export function shuffle(array: T): T { + let currentIndex = array.length, randomIndex; + + // While there remain elements to shuffle. + while (currentIndex !== 0) { + // Pick a remaining element. + randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex--; + + // And swap it with the current element. + [array[currentIndex], array[randomIndex]] = [ + array[randomIndex], array[currentIndex]]; + } + + return array; +} diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts new file mode 100644 index 0000000000..9d1f603235 --- /dev/null +++ b/packages/frontend/src/scripts/sound.ts @@ -0,0 +1,66 @@ +import { ColdDeviceStorage } from '@/store'; + +const cache = new Map(); + +export const soundsTypes = [ + null, + 'syuilo/up', + 'syuilo/down', + 'syuilo/pope1', + 'syuilo/pope2', + 'syuilo/waon', + 'syuilo/popo', + 'syuilo/triple', + '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 function getAudio(file: string, useCache = true): HTMLAudioElement { + let audio: HTMLAudioElement; + if (useCache && cache.has(file)) { + audio = cache.get(file); + } else { + audio = new Audio(`/client-assets/sounds/${file}.mp3`); + if (useCache) cache.set(file, audio); + } + return audio; +} + +export function setVolume(audio: HTMLAudioElement, volume: number): HTMLAudioElement { + const masterVolume = ColdDeviceStorage.get('sound_masterVolume'); + audio.volume = masterVolume - ((1 - volume) * masterVolume); + return audio; +} + +export function play(type: string) { + const sound = ColdDeviceStorage.get('sound_' + type as any); + if (sound.type == null) return; + playFile(sound.type, sound.volume); +} + +export function playFile(file: string, volume: number) { + const masterVolume = ColdDeviceStorage.get('sound_masterVolume'); + if (masterVolume === 0) return; + + const audio = setVolume(getAudio(file), volume); + audio.play(); +} diff --git a/packages/frontend/src/scripts/sticky-sidebar.ts b/packages/frontend/src/scripts/sticky-sidebar.ts new file mode 100644 index 0000000000..c67b8f37ac --- /dev/null +++ b/packages/frontend/src/scripts/sticky-sidebar.ts @@ -0,0 +1,50 @@ +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: number = 59; + + constructor(container: StickySidebar['container'], marginTop = 0, globalHeaderHeight = 0) { + this.container = container; + this.el = this.container.children[0] as HTMLElement; + this.el.style.position = 'sticky'; + this.spacer = document.createElement('div'); + this.container.prepend(this.spacer); + this.marginTop = marginTop; + this.offsetTop = this.container.getBoundingClientRect().top; + this.globalHeaderHeight = globalHeaderHeight; + } + + public calc(scrollTop: number) { + if (scrollTop > this.lastScrollTop) { // downscroll + const overflow = Math.max(0, this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight); + this.el.style.bottom = null; + this.el.style.top = `${-overflow + this.marginTop + this.globalHeaderHeight}px`; + + this.isBottom = (scrollTop + window.innerHeight) >= (this.el.offsetTop + this.el.clientHeight); + + if (this.isTop) { + this.isTop = false; + this.spacer.style.marginTop = `${Math.max(0, this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop)}px`; + } + } else { // upscroll + const overflow = this.globalHeaderHeight + (this.el.clientHeight + this.marginTop) - window.innerHeight; + this.el.style.top = null; + this.el.style.bottom = `${-overflow}px`; + + this.isTop = scrollTop + this.marginTop + this.globalHeaderHeight <= this.el.offsetTop; + + if (this.isBottom) { + this.isBottom = false; + this.spacer.style.marginTop = `${this.globalHeaderHeight + this.lastScrollTop + this.marginTop - this.offsetTop - overflow}px`; + } + } + + this.lastScrollTop = scrollTop <= 0 ? 0 : scrollTop; + } +} diff --git a/packages/frontend/src/scripts/theme-editor.ts b/packages/frontend/src/scripts/theme-editor.ts new file mode 100644 index 0000000000..944875ff15 --- /dev/null +++ b/packages/frontend/src/scripts/theme-editor.ts @@ -0,0 +1,81 @@ +import { v4 as uuid } from 'uuid'; + +import { themeProps, Theme } from './theme'; + +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.substr(1).trim(), + }; + } else { + return str; + } +}; + +export const toThemeString = (value: Color | Func | RefProp | RefConst | Css) => { + if (typeof value === 'string') return value; + switch (value.type) { + case 'func': return `:${value.name}<${value.arg}<@${value.value}`; + case 'refProp': return `@${value.key}`; + case 'refConst': return `$${value.key}`; + case 'css': return `" ${value.value}`; + } +}; + +export const convertToMisskeyTheme = (vm: ThemeViewModel, name: string, desc: string, author: string, base: 'dark' | 'light'): Theme => { + const props = { } as { [key: string]: string }; + for (const [key, value] of vm) { + if (value === null) continue; + props[key] = toThemeString(value); + } + + return { + id: uuid(), + name, desc, author, props, base, + }; +}; + +export const convertToViewModel = (theme: Theme): ThemeViewModel => { + const vm: ThemeViewModel = []; + // プロパティの登録 + vm.push(...themeProps.map(key => [key, fromThemeString(theme.props[key])] as [ string, ThemeValue ])); + + // 定数の登録 + const consts = Object + .keys(theme.props) + .filter(k => k.startsWith('$')) + .map(k => [k, fromThemeString(theme.props[k])] as [ string, ThemeValue ]); + + vm.push(...consts); + return vm; +}; diff --git a/packages/frontend/src/scripts/theme.ts b/packages/frontend/src/scripts/theme.ts new file mode 100644 index 0000000000..62a2b9459a --- /dev/null +++ b/packages/frontend/src/scripts/theme.ts @@ -0,0 +1,148 @@ +import { ref } from 'vue'; +import tinycolor from 'tinycolor2'; +import { globalEvents } from '@/events'; + +export type Theme = { + id: string; + name: string; + author: string; + desc?: string; + base?: 'dark' | 'light'; + props: Record; +}; + +import lightTheme from '@/themes/_light.json5'; +import darkTheme from '@/themes/_dark.json5'; +import { deepClone } from './clone'; + +export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X')); + +export const getBuiltinThemes = () => Promise.all( + [ + 'l-light', + 'l-coffee', + 'l-apricot', + 'l-rainy', + 'l-vivid', + 'l-cherry', + 'l-sushi', + 'l-u0', + + 'd-dark', + 'd-persimmon', + 'd-astro', + 'd-future', + 'd-botanical', + 'd-green-lime', + 'd-green-orange', + 'd-cherry', + 'd-ice', + 'd-u0', + ].map(name => import(`../themes/${name}.json5`).then(({ default: _default }): Theme => _default)), +); + +export const getBuiltinThemesRef = () => { + const builtinThemes = ref([]); + getBuiltinThemes().then(themes => builtinThemes.value = themes); + return builtinThemes; +}; + +let timeout = null; + +export function applyTheme(theme: Theme, persist = true) { + if (timeout) window.clearTimeout(timeout); + + document.documentElement.classList.add('_themeChanging_'); + + timeout = window.setTimeout(() => { + document.documentElement.classList.remove('_themeChanging_'); + }, 1000); + + const colorSchema = theme.base === 'dark' ? 'dark' : 'light'; + + // Deep copy + const _theme = deepClone(theme); + + if (_theme.base) { + const base = [lightTheme, darkTheme].find(x => x.id === _theme.base); + if (base) _theme.props = Object.assign({}, base.props, _theme.props); + } + + const props = compile(_theme); + + for (const tag of document.head.children) { + if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { + tag.setAttribute('content', props['htmlThemeColor']); + break; + } + } + + for (const [k, v] of Object.entries(props)) { + document.documentElement.style.setProperty(`--${k}`, v.toString()); + } + + document.documentElement.style.setProperty('color-schema', colorSchema); + + if (persist) { + localStorage.setItem('theme', JSON.stringify(props)); + localStorage.setItem('colorSchema', colorSchema); + } + + // 色計算など再度行えるようにクライアント全体に通知 + globalEvents.emit('themeChanged'); +} + +function compile(theme: Theme): Record { + function getColor(val: string): tinycolor.Instance { + // ref (prop) + if (val[0] === '@') { + return getColor(theme.props[val.substr(1)]); + } + + // ref (const) + else if (val[0] === '$') { + return getColor(theme.props[val]); + } + + // func + else if (val[0] === ':') { + const parts = val.split('<'); + const func = parts.shift().substr(1); + const arg = parseFloat(parts.shift()); + const color = getColor(parts.join('<')); + + switch (func) { + case 'darken': return color.darken(arg); + case 'lighten': return color.lighten(arg); + case 'alpha': return color.setAlpha(arg); + case 'hue': return color.spin(arg); + case 'saturate': return color.saturate(arg); + } + } + + // other case + return tinycolor(val); + } + + const props = {}; + + for (const [k, v] of Object.entries(theme.props)) { + if (k.startsWith('$')) continue; // ignore const + + props[k] = v.startsWith('"') ? v.replace(/^"\s*/, '') : genValue(getColor(v)); + } + + return props; +} + +function genValue(c: tinycolor.Instance): string { + return c.toRgbString(); +} + +export function validateTheme(theme: Record): boolean { + if (theme.id == null || typeof theme.id !== 'string') return false; + if (theme.name == null || typeof theme.name !== 'string') return false; + if (theme.base == null || !['light', 'dark'].includes(theme.base)) return false; + if (theme.props == null || typeof theme.props !== 'object') return false; + return true; +} diff --git a/packages/frontend/src/scripts/time.ts b/packages/frontend/src/scripts/time.ts new file mode 100644 index 0000000000..34e8b6b17c --- /dev/null +++ b/packages/frontend/src/scripts/time.ts @@ -0,0 +1,39 @@ +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 'wrong number of arguments'; + + return new Date(d); +} + +export function isTimeSame(a: Date, b: Date): boolean { + return a.getTime() === b.getTime(); +} + +export function isTimeBefore(a: Date, b: Date): boolean { + return (a.getTime() - b.getTime()) < 0; +} + +export function isTimeAfter(a: Date, b: Date): boolean { + return (a.getTime() - b.getTime()) > 0; +} + +export function addTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date { + return new Date(x.getTime() + (value * dateTimeIntervals[span])); +} + +export function subtractTime(x: Date, value: number, span: keyof typeof dateTimeIntervals = 'ms'): Date { + return new Date(x.getTime() - (value * dateTimeIntervals[span])); +} diff --git a/packages/frontend/src/scripts/timezones.ts b/packages/frontend/src/scripts/timezones.ts new file mode 100644 index 0000000000..8ce07323f6 --- /dev/null +++ b/packages/frontend/src/scripts/timezones.ts @@ -0,0 +1,49 @@ +export const timezones = [{ + name: 'UTC', + abbrev: 'UTC', + offset: 0, +}, { + name: 'Europe/Berlin', + abbrev: 'CET', + offset: 60, +}, { + name: 'Asia/Tokyo', + abbrev: 'JST', + offset: 540, +}, { + name: 'Asia/Seoul', + abbrev: 'KST', + offset: 540, +}, { + name: 'Asia/Shanghai', + abbrev: 'CST', + offset: 480, +}, { + name: 'Australia/Sydney', + abbrev: 'AEST', + offset: 600, +}, { + name: 'Australia/Darwin', + abbrev: 'ACST', + offset: 570, +}, { + name: 'Australia/Perth', + abbrev: 'AWST', + offset: 480, +}, { + name: 'America/New_York', + abbrev: 'EST', + offset: -300, +}, { + name: 'America/Mexico_City', + abbrev: 'CST', + offset: -360, +}, { + name: 'America/Phoenix', + abbrev: 'MST', + offset: -420, +}, { + name: 'America/Los_Angeles', + abbrev: 'PST', + offset: -480, +}]; diff --git a/packages/frontend/src/scripts/touch.ts b/packages/frontend/src/scripts/touch.ts new file mode 100644 index 0000000000..5251bc2e27 --- /dev/null +++ b/packages/frontend/src/scripts/touch.ts @@ -0,0 +1,23 @@ +const isTouchSupported = 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 0; + +export let isTouchUsing = false; + +export let isScreenTouching = false; + +if (isTouchSupported) { + window.addEventListener('touchstart', () => { + // maxTouchPointsなどでの判定だけだと、「タッチ機能付きディスプレイを使っているがマウスでしか操作しない」場合にも + // タッチで使っていると判定されてしまうため、実際に一度でもタッチされたらtrueにする + isTouchUsing = true; + + isScreenTouching = true; + }, { passive: true }); + + window.addEventListener('touchend', () => { + // 子要素のtouchstartイベントでstopPropagation()が呼ばれると親要素に伝搬されずタッチされたと判定されないため、 + // touchendイベントでもtouchstartイベントと同様にtrueにする + isTouchUsing = true; + + isScreenTouching = false; + }, { passive: true }); +} diff --git a/packages/frontend/src/scripts/unison-reload.ts b/packages/frontend/src/scripts/unison-reload.ts new file mode 100644 index 0000000000..59af584c1b --- /dev/null +++ b/packages/frontend/src/scripts/unison-reload.ts @@ -0,0 +1,15 @@ +// SafariがBroadcastChannel未実装なのでライブラリを使う +import { BroadcastChannel } from 'broadcast-channel'; + +export const reloadChannel = new BroadcastChannel('reload'); + +// BroadcastChannelを用いて、クライアントが一斉にreloadするようにします。 +export function unisonReload(path?: string) { + if (path !== undefined) { + reloadChannel.postMessage(path); + location.href = path; + } else { + reloadChannel.postMessage(null); + location.reload(); + } +} diff --git a/packages/frontend/src/scripts/upload.ts b/packages/frontend/src/scripts/upload.ts new file mode 100644 index 0000000000..9a39652ef5 --- /dev/null +++ b/packages/frontend/src/scripts/upload.ts @@ -0,0 +1,137 @@ +import { reactive, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { readAndCompressImage } from 'browser-image-resizer'; +import { getCompressionConfig } from './upload/compress-config'; +import { defaultStore } from '@/store'; +import { apiUrl } from '@/config'; +import { $i } from '@/account'; +import { alert } from '@/os'; +import { i18n } from '@/i18n'; + +type Uploading = { + id: string; + name: string; + progressMax: number | undefined; + progressValue: number | undefined; + img: string; +}; +export const uploads = ref([]); + +const mimeTypeMap = { + 'image/webp': 'webp', + 'image/jpeg': 'jpg', + 'image/png': 'png', +} as const; + +export function uploadFile( + file: File, + folder?: any, + name?: string, + keepOriginal: boolean = defaultStore.state.keepOriginalUploading, +): Promise { + if ($i == null) throw new Error('Not logged in'); + + if (folder && typeof folder === 'object') folder = folder.id; + + return new Promise((resolve, reject) => { + const id = Math.random().toString(); + + const reader = new FileReader(); + reader.onload = async (): Promise => { + const ctx = reactive({ + id: id, + name: name ?? file.name ?? 'untitled', + 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) => { + if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { + // TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい + uploads.value = uploads.value.filter(x => x.id !== id); + + 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) => any; + + xhr.upload.onprogress = ev => { + if (ev.lengthComputable) { + ctx.progressMax = ev.total; + ctx.progressValue = ev.loaded; + } + }; + + xhr.send(formData); + }; + reader.readAsArrayBuffer(file); + }); +} diff --git a/packages/frontend/src/scripts/upload/compress-config.ts b/packages/frontend/src/scripts/upload/compress-config.ts new file mode 100644 index 0000000000..793c78ad20 --- /dev/null +++ b/packages/frontend/src/scripts/upload/compress-config.ts @@ -0,0 +1,23 @@ +import isAnimated from 'is-file-animated'; +import type { BrowserImageResizerConfig } from 'browser-image-resizer'; + +const compressTypeMap = { + '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 { + const imgConfig = compressTypeMap[file.type]; + if (!imgConfig || await isAnimated(file)) { + return; + } + + return { + maxWidth: 2048, + maxHeight: 2048, + debug: true, + ...imgConfig, + }; +} diff --git a/packages/frontend/src/scripts/url.ts b/packages/frontend/src/scripts/url.ts new file mode 100644 index 0000000000..86735de9f0 --- /dev/null +++ b/packages/frontend/src/scripts/url.ts @@ -0,0 +1,13 @@ +export function query(obj: Record): string { + const params = Object.entries(obj) + .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) + .reduce((a, [k, v]) => (a[k] = v, a), {} as Record); + + return Object.entries(params) + .map((p) => `${p[0]}=${encodeURIComponent(p[1])}`) + .join('&'); +} + +export function appendQuery(url: string, query: string): string { + return `${url}${/\?/.test(url) ? url.endsWith('?') ? '' : '&' : '?'}${query}`; +} diff --git a/packages/frontend/src/scripts/use-chart-tooltip.ts b/packages/frontend/src/scripts/use-chart-tooltip.ts new file mode 100644 index 0000000000..881e5e9ad5 --- /dev/null +++ b/packages/frontend/src/scripts/use-chart-tooltip.ts @@ -0,0 +1,54 @@ +import { onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import MkChartTooltip from '@/components/MkChartTooltip.vue'; + +export function useChartTooltip(opts: { position: 'top' | 'middle' } = { position: 'top' }) { + const tooltipShowing = ref(false); + const tooltipX = ref(0); + const tooltipY = ref(0); + const tooltipTitle = ref(null); + const tooltipSeries = ref(null); + let disposeTooltipComponent; + + os.popup(MkChartTooltip, { + showing: tooltipShowing, + x: tooltipX, + y: tooltipY, + title: tooltipTitle, + series: tooltipSeries, + }, {}).then(({ dispose }) => { + disposeTooltipComponent = dispose; + }); + + onUnmounted(() => { + if (disposeTooltipComponent) disposeTooltipComponent(); + }); + + function handler(context) { + if (context.tooltip.opacity === 0) { + tooltipShowing.value = false; + return; + } + + tooltipTitle.value = context.tooltip.title[0]; + tooltipSeries.value = context.tooltip.body.map((b, i) => ({ + backgroundColor: context.tooltip.labelColors[i].backgroundColor, + borderColor: context.tooltip.labelColors[i].borderColor, + text: b.lines[0], + })); + + const rect = context.chart.canvas.getBoundingClientRect(); + + tooltipShowing.value = true; + tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX; + if (opts.position === 'top') { + tooltipY.value = rect.top + window.pageYOffset; + } else if (opts.position === 'middle') { + tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY; + } + } + + return { + handler, + }; +} diff --git a/packages/frontend/src/scripts/use-interval.ts b/packages/frontend/src/scripts/use-interval.ts new file mode 100644 index 0000000000..201ba417ef --- /dev/null +++ b/packages/frontend/src/scripts/use-interval.ts @@ -0,0 +1,24 @@ +import { onMounted, onUnmounted } from 'vue'; + +export function useInterval(fn: () => void, interval: number, options: { + immediate: boolean; + afterMounted: boolean; +}): void { + if (Number.isNaN(interval)) return; + + let intervalId: number | null = null; + + if (options.afterMounted) { + onMounted(() => { + if (options.immediate) fn(); + intervalId = window.setInterval(fn, interval); + }); + } else { + if (options.immediate) fn(); + intervalId = window.setInterval(fn, interval); + } + + onUnmounted(() => { + if (intervalId) window.clearInterval(intervalId); + }); +} diff --git a/packages/frontend/src/scripts/use-leave-guard.ts b/packages/frontend/src/scripts/use-leave-guard.ts new file mode 100644 index 0000000000..a93b84d1fe --- /dev/null +++ b/packages/frontend/src/scripts/use-leave-guard.ts @@ -0,0 +1,47 @@ +import { inject, onUnmounted, Ref } from 'vue'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; + +export function useLeaveGuard(enabled: Ref) { + /* TODO + const setLeaveGuard = inject('setLeaveGuard'); + + if (setLeaveGuard) { + setLeaveGuard(async () => { + if (!enabled.value) return false; + + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.leaveConfirm, + }); + + return canceled; + }); + } else { + onBeforeRouteLeave(async (to, from) => { + if (!enabled.value) return true; + + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.leaveConfirm, + }); + + return !canceled; + }); + } + */ + + /* + function onBeforeLeave(ev: BeforeUnloadEvent) { + if (enabled.value) { + ev.preventDefault(); + ev.returnValue = ''; + } + } + + window.addEventListener('beforeunload', onBeforeLeave); + onUnmounted(() => { + window.removeEventListener('beforeunload', onBeforeLeave); + }); + */ +} diff --git a/packages/frontend/src/scripts/use-note-capture.ts b/packages/frontend/src/scripts/use-note-capture.ts new file mode 100644 index 0000000000..e6bdb345c4 --- /dev/null +++ b/packages/frontend/src/scripts/use-note-capture.ts @@ -0,0 +1,110 @@ +import { onUnmounted, Ref } from 'vue'; +import * as misskey from 'misskey-js'; +import { stream } from '@/stream'; +import { $i } from '@/account'; + +export function useNoteCapture(props: { + rootEl: Ref; + note: Ref; + isDeletedRef: Ref; +}) { + const note = props.note; + const connection = $i ? stream : null; + + function onStreamNoteUpdated(noteData): void { + const { type, id, body } = noteData; + + if (id !== note.value.id) return; + + switch (type) { + case 'reacted': { + const reaction = body.reaction; + + if (body.emoji) { + const emojis = note.value.emojis || []; + if (!emojis.includes(body.emoji)) { + note.value.emojis = [...emojis, body.emoji]; + } + } + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (note.value.reactions || {})[reaction] || 0; + + note.value.reactions[reaction] = currentCount + 1; + + if ($i && (body.userId === $i.id)) { + note.value.myReaction = reaction; + } + break; + } + + case 'unreacted': { + const reaction = body.reaction; + + // TODO: reactionsプロパティがない場合ってあったっけ? なければ || {} は消せる + const currentCount = (note.value.reactions || {})[reaction] || 0; + + note.value.reactions[reaction] = Math.max(0, currentCount - 1); + + if ($i && (body.userId === $i.id)) { + note.value.myReaction = null; + } + break; + } + + case 'pollVoted': { + const choice = body.choice; + + const choices = [...note.value.poll.choices]; + choices[choice] = { + ...choices[choice], + votes: choices[choice].votes + 1, + ...($i && (body.userId === $i.id) ? { + isVoted: true, + } : {}), + }; + + note.value.poll.choices = choices; + break; + } + + case 'deleted': { + props.isDeletedRef.value = true; + break; + } + } + } + + function capture(withHandler = false): void { + if (connection) { + // TODO: このノートがストリーミング経由で流れてきた場合のみ sr する + connection.send(document.body.contains(props.rootEl.value) ? 'sr' : 's', { id: note.value.id }); + if (withHandler) connection.on('noteUpdated', onStreamNoteUpdated); + } + } + + function decapture(withHandler = false): void { + if (connection) { + connection.send('un', { + id: note.value.id, + }); + if (withHandler) connection.off('noteUpdated', onStreamNoteUpdated); + } + } + + function onStreamConnected() { + capture(false); + } + + capture(true); + if (connection) { + connection.on('_connected_', onStreamConnected); + } + + onUnmounted(() => { + decapture(true); + if (connection) { + connection.off('_connected_', onStreamConnected); + } + }); +} diff --git a/packages/frontend/src/scripts/use-tooltip.ts b/packages/frontend/src/scripts/use-tooltip.ts new file mode 100644 index 0000000000..1f6e0fb6ce --- /dev/null +++ b/packages/frontend/src/scripts/use-tooltip.ts @@ -0,0 +1,86 @@ +import { Ref, ref, watch, onUnmounted } from 'vue'; + +export function useTooltip( + elRef: Ref, + onShow: (showing: Ref) => void, + delay = 300, +): void { + let isHovering = false; + + // iOS(Androidも?)では、要素をタップした直後に(おせっかいで)mouseoverイベントを発火させたりするため、それを無視するためのフラグ + // 無視しないと、画面に触れてないのにツールチップが出たりし、ユーザビリティが損なわれる + // TODO: 一度でもタップすると二度とマウスでツールチップ出せなくなるのをどうにかする 定期的にfalseに戻すとか...? + let shouldIgnoreMouseover = false; + + let timeoutId: number; + + let changeShowingState: (() => void) | null; + + const open = () => { + close(); + if (!isHovering) return; + if (elRef.value == null) return; + const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el; + if (!document.body.contains(el)) return; // openしようとしたときに既に元要素がDOMから消えている場合があるため + + const showing = ref(true); + onShow(showing); + changeShowingState = () => { + showing.value = false; + }; + }; + + const close = () => { + if (changeShowingState != null) { + changeShowingState(); + changeShowingState = null; + } + }; + + const onMouseover = () => { + if (isHovering) return; + if (shouldIgnoreMouseover) return; + isHovering = true; + timeoutId = window.setTimeout(open, delay); + }; + + const onMouseleave = () => { + if (!isHovering) return; + isHovering = false; + window.clearTimeout(timeoutId); + close(); + }; + + const onTouchstart = () => { + shouldIgnoreMouseover = true; + if (isHovering) return; + isHovering = true; + timeoutId = window.setTimeout(open, delay); + }; + + const onTouchend = () => { + if (!isHovering) return; + isHovering = false; + window.clearTimeout(timeoutId); + close(); + }; + + const stop = watch(elRef, () => { + if (elRef.value) { + stop(); + const el = elRef.value instanceof Element ? elRef.value : elRef.value.$el; + el.addEventListener('mouseover', onMouseover, { passive: true }); + el.addEventListener('mouseleave', onMouseleave, { passive: true }); + el.addEventListener('touchstart', onTouchstart, { passive: true }); + el.addEventListener('touchend', onTouchend, { passive: true }); + el.addEventListener('click', close, { passive: true }); + } + }, { + immediate: true, + flush: 'post', + }); + + onUnmounted(() => { + close(); + }); +} -- cgit v1.2.3-freya From e584b7f1cf4103acbe8048dbb9816c9f6c7d0e27 Mon Sep 17 00:00:00 2001 From: syuilo Date: Wed, 28 Dec 2022 09:10:33 +0900 Subject: :art: --- packages/frontend/src/scripts/get-user-menu.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) (limited to 'packages/frontend/src/scripts') diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 2faacffdfc..7ede64c327 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -158,18 +158,18 @@ export function getUserMenu(user, router: Router = mainRouter) { action: () => { copyToClipboard(`@${user.username}@${user.host || host}`); }, - }, { - icon: 'ti ti-rss', - text: i18n.ts.copyRSS, - action: () => { - copyToClipboard(`${user.host || host}/@${user.username}.atom`); - } }, { icon: 'ti ti-info-circle', text: i18n.ts.info, action: () => { router.push(`/user-info/${user.id}`); }, + }, { + icon: 'ti ti-rss', + text: i18n.ts.copyRSS, + action: () => { + copyToClipboard(`${user.host ?? host}/@${user.username}.atom`); + }, }, { icon: 'ti ti-mail', text: i18n.ts.sendMessage, -- cgit v1.2.3-freya From f6b1f91cfa1aafd40dd05d9803d6118141ec2d6e Mon Sep 17 00:00:00 2001 From: syuilo Date: Wed, 28 Dec 2022 13:15:31 +0900 Subject: fix(client): チャートのツールチップが画面に残ることがあるのを修正 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix #9290 --- CHANGELOG.md | 1 + packages/frontend/src/scripts/use-chart-tooltip.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) (limited to 'packages/frontend/src/scripts') diff --git a/CHANGELOG.md b/CHANGELOG.md index f8bfa1d50d..9203d6b6ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ You should also include the user name that made the change. - Client: use proxied image for instance icon @syuilo - Client: Webhookの編集画面で、内容を保存することができない問題を修正 @m-hayabusa - Client: update emoji picker immediately on all input @saschanaz +- Client: チャートのツールチップが画面に残ることがあるのを修正 @syuilo ## 12.119.1 (2022/12/03) ### Bugfixes diff --git a/packages/frontend/src/scripts/use-chart-tooltip.ts b/packages/frontend/src/scripts/use-chart-tooltip.ts index 881e5e9ad5..6f40fd4a30 100644 --- a/packages/frontend/src/scripts/use-chart-tooltip.ts +++ b/packages/frontend/src/scripts/use-chart-tooltip.ts @@ -1,4 +1,4 @@ -import { onUnmounted, ref } from 'vue'; +import { onUnmounted, onDeactivated, ref } from 'vue'; import * as os from '@/os'; import MkChartTooltip from '@/components/MkChartTooltip.vue'; @@ -24,6 +24,10 @@ export function useChartTooltip(opts: { position: 'top' | 'middle' } = { positio if (disposeTooltipComponent) disposeTooltipComponent(); }); + onDeactivated(() => { + tooltipShowing.value = false; + }); + function handler(context) { if (context.tooltip.opacity === 0) { tooltipShowing.value = false; -- cgit v1.2.3-freya From 2547c8c11799ee9bc997185d4472b7403986d6d7 Mon Sep 17 00:00:00 2001 From: syuilo Date: Wed, 28 Dec 2022 13:42:30 +0900 Subject: refactor --- .../src/components/MkActiveUsersHeatmap.vue | 9 +-- packages/frontend/src/pages/admin/metrics.vue | 65 ++++++++++------------ .../src/pages/admin/overview.active-users.vue | 9 +-- .../src/pages/admin/overview.ap-requests.vue | 9 +-- .../src/pages/admin/overview.queue.chart.vue | 9 +-- .../frontend/src/pages/admin/queue.chart.chart.vue | 9 +-- packages/frontend/src/scripts/color.ts | 7 +++ 7 files changed, 41 insertions(+), 76 deletions(-) create mode 100644 packages/frontend/src/scripts/color.ts (limited to 'packages/frontend/src/scripts') diff --git a/packages/frontend/src/components/MkActiveUsersHeatmap.vue b/packages/frontend/src/components/MkActiveUsersHeatmap.vue index 9204c7312a..6b89a012f2 100644 --- a/packages/frontend/src/components/MkActiveUsersHeatmap.vue +++ b/packages/frontend/src/components/MkActiveUsersHeatmap.vue @@ -34,6 +34,7 @@ import { defaultStore } from '@/store'; import { useChartTooltip } from '@/scripts/use-chart-tooltip'; import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; import { chartVLine } from '@/scripts/chart-vline'; +import { alpha } from '@/scripts/color'; Chart.register( ArcElement, @@ -53,14 +54,6 @@ Chart.register( MatrixController, MatrixElement, ); -const alpha = (hex, a) => { - 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})`; -}; - const rootEl = $ref(null); const chartEl = $ref(null); const now = new Date(); diff --git a/packages/frontend/src/pages/admin/metrics.vue b/packages/frontend/src/pages/admin/metrics.vue index db8e448639..6c4803fe0b 100644 --- a/packages/frontend/src/pages/admin/metrics.vue +++ b/packages/frontend/src/pages/admin/metrics.vue @@ -53,55 +53,48 @@ diff --git a/packages/frontend/src/pages/user/activity.pv.vue b/packages/frontend/src/pages/user/activity.pv.vue new file mode 100644 index 0000000000..d709bc01b9 --- /dev/null +++ b/packages/frontend/src/pages/user/activity.pv.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/packages/frontend/src/pages/user/activity.vue b/packages/frontend/src/pages/user/activity.vue new file mode 100644 index 0000000000..f9dce3a9e8 --- /dev/null +++ b/packages/frontend/src/pages/user/activity.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/packages/frontend/src/pages/user/index.activity.vue b/packages/frontend/src/pages/user/index.activity.vue index 523072d2e6..0cc1524663 100644 --- a/packages/frontend/src/pages/user/index.activity.vue +++ b/packages/frontend/src/pages/user/index.activity.vue @@ -33,10 +33,16 @@ let chartSrc = $ref('per-user-notes'); function showMenu(ev: MouseEvent) { os.popupMenu([{ text: i18n.ts.notes, - active: true, + active: chartSrc === 'per-user-notes', action: () => { chartSrc = 'per-user-notes'; }, + }, { + text: i18n.ts.numberOfProfileView, + active: chartSrc === 'per-user-pv', + action: () => { + chartSrc = 'per-user-pv'; + }, }, /*, { text: i18n.ts.following, action: () => { diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue index f40cd0b8d6..b60cef3729 100644 --- a/packages/frontend/src/pages/user/index.vue +++ b/packages/frontend/src/pages/user/index.vue @@ -5,6 +5,7 @@
+ @@ -32,6 +33,7 @@ import { i18n } from '@/i18n'; import { $i } from '@/account'; const XHome = defineAsyncComponent(() => import('./home.vue')); +const XActivity = defineAsyncComponent(() => import('./activity.vue')); const XReactions = defineAsyncComponent(() => import('./reactions.vue')); const XClips = defineAsyncComponent(() => import('./clips.vue')); const XPages = defineAsyncComponent(() => import('./pages.vue')); @@ -70,6 +72,10 @@ const headerTabs = $computed(() => user ? [{ key: 'home', title: i18n.ts.overview, icon: 'ti ti-home', +}, { + key: 'activity', + title: i18n.ts.activity, + icon: 'ti ti-chart-line', }, ...($i && ($i.id === user.id)) || user.publicReactions ? [{ key: 'reactions', title: i18n.ts.reaction, diff --git a/packages/frontend/src/scripts/init-chart.ts b/packages/frontend/src/scripts/init-chart.ts new file mode 100644 index 0000000000..32f887f2e7 --- /dev/null +++ b/packages/frontend/src/scripts/init-chart.ts @@ -0,0 +1,44 @@ +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'; + +export function initChart() { + Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + DoughnutController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + MatrixController, MatrixElement, + zoomPlugin, + gradient, + ); +} -- cgit v1.2.3-freya From d98771711ffa2bf689ff5c6b8c1adb88ea0f4dca Mon Sep 17 00:00:00 2001 From: syuilo Date: Mon, 2 Jan 2023 12:01:06 +0900 Subject: fix(client): fix chart vline rendering --- packages/frontend/src/scripts/chart-vline.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'packages/frontend/src/scripts') diff --git a/packages/frontend/src/scripts/chart-vline.ts b/packages/frontend/src/scripts/chart-vline.ts index 8e3c4436b2..10021583e0 100644 --- a/packages/frontend/src/scripts/chart-vline.ts +++ b/packages/frontend/src/scripts/chart-vline.ts @@ -2,9 +2,9 @@ export const chartVLine = (vLineColor: string) => ({ id: 'vLine', beforeDraw(chart, args, options) { if (chart.tooltip?._active?.length) { - const activePoint = chart.tooltip._active[0]; const ctx = chart.ctx; - const x = activePoint.element.x; + 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; -- cgit v1.2.3-freya From f598a876eb4b8e832c9e6285a564ffde0d90b003 Mon Sep 17 00:00:00 2001 From: syuilo Date: Mon, 2 Jan 2023 19:58:51 +0900 Subject: refactor --- packages/frontend/src/components/MkChart.vue | 9 --------- packages/frontend/src/components/MkHeatmap.vue | 10 ---------- packages/frontend/src/components/MkRetentionHeatmap.vue | 10 ---------- .../frontend/src/pages/admin/overview.active-users.vue | 9 --------- packages/frontend/src/pages/admin/overview.ap-requests.vue | 14 -------------- packages/frontend/src/pages/admin/overview.pie.vue | 3 --- packages/frontend/src/pages/admin/overview.queue.chart.vue | 9 --------- packages/frontend/src/pages/admin/queue.chart.chart.vue | 9 --------- packages/frontend/src/pages/user/activity.heatmap.vue | 10 ---------- packages/frontend/src/pages/user/activity.pv.vue | 9 --------- packages/frontend/src/scripts/init-chart.ts | 8 ++++++++ 11 files changed, 8 insertions(+), 92 deletions(-) (limited to 'packages/frontend/src/scripts') diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index 9ca7deaf80..d602849651 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -128,12 +128,8 @@ const render = () => { chartInstance.destroy(); } - const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; - // フォントカラー - Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - const maxes = chartData.series.map((x, i) => Math.max(...x.data.map(d => d.y))); chartInstance = new Chart(chartEl.value, { @@ -188,8 +184,6 @@ const render = () => { unit: props.span === 'day' ? 'month' : 'day', }, grid: { - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { display: props.detailed, @@ -208,8 +202,6 @@ const render = () => { stacked: props.stacked, suggestedMax: 50, grid: { - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { display: props.detailed, @@ -227,7 +219,6 @@ const render = () => { hoverBorderWidth: 2, }, }, - animation: false, plugins: { legend: { display: props.detailed, diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue index 5350928bfe..f7a2db8509 100644 --- a/packages/frontend/src/components/MkHeatmap.vue +++ b/packages/frontend/src/components/MkHeatmap.vue @@ -92,11 +92,6 @@ async function renderChart() { await nextTick(); - const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; - - // フォントカラー - Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300'; // 視覚上の分かりやすさのため上から最も大きい3つの値の平均を最大値とする @@ -160,8 +155,6 @@ async function renderChart() { }, grid: { display: false, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { display: true, @@ -175,8 +168,6 @@ async function renderChart() { position: 'right', grid: { display: false, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { maxRotation: 0, @@ -189,7 +180,6 @@ async function renderChart() { }, }, }, - animation: false, plugins: { legend: { display: false, diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue index b42c4f29a5..4dcf0cef9c 100644 --- a/packages/frontend/src/components/MkRetentionHeatmap.vue +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -64,11 +64,6 @@ async function renderChart() { await nextTick(); - const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; - - // フォントカラー - Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300'; // 視覚上の分かりやすさのため上から最も大きい3つの値の平均を最大値とする @@ -119,8 +114,6 @@ async function renderChart() { suggestedMax: maxDays, grid: { display: false, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { display: true, @@ -143,8 +136,6 @@ async function renderChart() { }, grid: { display: false, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { maxRotation: 0, @@ -156,7 +147,6 @@ async function renderChart() { }, }, }, - animation: false, plugins: { legend: { display: false, diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue index 190635c754..d760d90ef1 100644 --- a/packages/frontend/src/pages/admin/overview.active-users.vue +++ b/packages/frontend/src/pages/admin/overview.active-users.vue @@ -53,12 +53,8 @@ async function renderChart() { const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' }); - const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; - // フォントカラー - Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - const colorRead = '#3498db'; const colorWrite = '#2ecc71'; @@ -113,8 +109,6 @@ async function renderChart() { }, grid: { display: false, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { display: true, @@ -132,8 +126,6 @@ async function renderChart() { suggestedMax: 10, grid: { display: true, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { display: true, @@ -145,7 +137,6 @@ async function renderChart() { intersect: false, mode: 'index', }, - animation: false, plugins: { legend: { display: false, diff --git a/packages/frontend/src/pages/admin/overview.ap-requests.vue b/packages/frontend/src/pages/admin/overview.ap-requests.vue index fa6a6f30d3..5dcb67474f 100644 --- a/packages/frontend/src/pages/admin/overview.ap-requests.vue +++ b/packages/frontend/src/pages/admin/overview.ap-requests.vue @@ -68,7 +68,6 @@ onMounted(async () => { const raw = await os.api('charts/ap-request', { limit: chartLimit, span: 'day' }); - const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const succColor = '#87e000'; const failColor = '#ff4400'; @@ -76,9 +75,6 @@ onMounted(async () => { const succMax = Math.max(...raw.deliverSucceeded); const failMax = Math.max(...raw.deliverFailed); - // フォントカラー - Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - new Chart(chartEl, { type: 'line', data: { @@ -133,8 +129,6 @@ onMounted(async () => { }, grid: { display: true, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { display: true, @@ -154,8 +148,6 @@ onMounted(async () => { suggestedMax: 10, grid: { display: true, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { display: true, @@ -174,7 +166,6 @@ onMounted(async () => { hoverBorderWidth: 2, }, }, - animation: false, plugins: { legend: { display: false, @@ -232,8 +223,6 @@ onMounted(async () => { }, grid: { display: false, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { display: false, @@ -252,8 +241,6 @@ onMounted(async () => { suggestedMax: 10, grid: { display: true, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, }, }, @@ -267,7 +254,6 @@ onMounted(async () => { hoverBorderWidth: 2, }, }, - animation: false, plugins: { legend: { display: false, diff --git a/packages/frontend/src/pages/admin/overview.pie.vue b/packages/frontend/src/pages/admin/overview.pie.vue index 33ab6fe851..b6f0d1b705 100644 --- a/packages/frontend/src/pages/admin/overview.pie.vue +++ b/packages/frontend/src/pages/admin/overview.pie.vue @@ -18,9 +18,6 @@ const props = defineProps<{ const chartEl = ref(null); -// フォントカラー -Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - const { handler: externalTooltipHandler } = useChartTooltip({ position: 'middle', }); diff --git a/packages/frontend/src/pages/admin/overview.queue.chart.vue b/packages/frontend/src/pages/admin/overview.queue.chart.vue index bb51ffd68f..1765577d45 100644 --- a/packages/frontend/src/pages/admin/overview.queue.chart.vue +++ b/packages/frontend/src/pages/admin/overview.queue.chart.vue @@ -21,11 +21,6 @@ const props = defineProps<{ const chartEl = ref(null); -const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; - -// フォントカラー -Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - const { handler: externalTooltipHandler } = useChartTooltip(); let chartInstance: Chart; @@ -101,8 +96,6 @@ onMounted(() => { x: { grid: { display: false, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { display: false, @@ -112,8 +105,6 @@ onMounted(() => { y: { min: 0, grid: { - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, }, }, diff --git a/packages/frontend/src/pages/admin/queue.chart.chart.vue b/packages/frontend/src/pages/admin/queue.chart.chart.vue index f95cd1c872..ae8d9ae4de 100644 --- a/packages/frontend/src/pages/admin/queue.chart.chart.vue +++ b/packages/frontend/src/pages/admin/queue.chart.chart.vue @@ -21,11 +21,6 @@ const props = defineProps<{ const chartEl = ref(null); -const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; - -// フォントカラー -Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - const { handler: externalTooltipHandler } = useChartTooltip(); let chartInstance: Chart; @@ -101,8 +96,6 @@ onMounted(() => { x: { grid: { display: true, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { display: false, @@ -112,8 +105,6 @@ onMounted(() => { y: { min: 0, grid: { - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, }, }, diff --git a/packages/frontend/src/pages/user/activity.heatmap.vue b/packages/frontend/src/pages/user/activity.heatmap.vue index 86e3a0f4f9..000653ea5c 100644 --- a/packages/frontend/src/pages/user/activity.heatmap.vue +++ b/packages/frontend/src/pages/user/activity.heatmap.vue @@ -81,11 +81,6 @@ async function renderChart() { await nextTick(); - const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; - - // フォントカラー - Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300'; // 視覚上の分かりやすさのため上から最も大きい3つの値の平均を最大値とする @@ -149,8 +144,6 @@ async function renderChart() { }, grid: { display: false, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { display: true, @@ -164,8 +157,6 @@ async function renderChart() { position: 'right', grid: { display: false, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { maxRotation: 0, @@ -178,7 +169,6 @@ async function renderChart() { }, }, }, - animation: false, plugins: { legend: { display: false, diff --git a/packages/frontend/src/pages/user/activity.pv.vue b/packages/frontend/src/pages/user/activity.pv.vue index d709bc01b9..25d708e71a 100644 --- a/packages/frontend/src/pages/user/activity.pv.vue +++ b/packages/frontend/src/pages/user/activity.pv.vue @@ -58,12 +58,8 @@ async function renderChart() { const raw = await os.api('charts/user/pv', { userId: props.user.id, limit: chartLimit, span: 'day' }); - const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; - // フォントカラー - Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - const colorUser = '#3498db'; const colorVisitor = '#2ecc71'; @@ -117,8 +113,6 @@ async function renderChart() { }, grid: { display: false, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { display: true, @@ -137,8 +131,6 @@ async function renderChart() { suggestedMax: 10, grid: { display: true, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', }, ticks: { display: true, @@ -150,7 +142,6 @@ async function renderChart() { intersect: false, mode: 'index', }, - animation: false, plugins: { title: { display: true, diff --git a/packages/frontend/src/scripts/init-chart.ts b/packages/frontend/src/scripts/init-chart.ts index 32f887f2e7..005ac5e271 100644 --- a/packages/frontend/src/scripts/init-chart.ts +++ b/packages/frontend/src/scripts/init-chart.ts @@ -19,6 +19,7 @@ import { import gradient from 'chartjs-plugin-gradient'; import zoomPlugin from 'chartjs-plugin-zoom'; import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; +import { defaultStore } from '@/store'; export function initChart() { Chart.register( @@ -41,4 +42,11 @@ export function initChart() { zoomPlugin, gradient, ); + + // フォントカラー + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + Chart.defaults.borderColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + + Chart.defaults.animation = false; } -- cgit v1.2.3-freya From 826e4502cb45441614e4881fab9307f85f4fd069 Mon Sep 17 00:00:00 2001 From: syuilo Date: Tue, 3 Jan 2023 13:09:24 +0900 Subject: refactor --- packages/frontend/src/components/MkChart.vue | 1 - packages/frontend/src/components/MkHeatmap.vue | 3 +-- packages/frontend/src/components/MkRetentionHeatmap.vue | 3 +-- packages/frontend/src/pages/admin/overview.active-users.vue | 3 +-- packages/frontend/src/pages/admin/overview.vue | 1 - packages/frontend/src/pages/user/activity.heatmap.vue | 1 - packages/frontend/src/pages/user/activity.pv.vue | 3 +-- packages/frontend/src/scripts/init-chart.ts | 1 + 8 files changed, 5 insertions(+), 11 deletions(-) (limited to 'packages/frontend/src/scripts') diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index 7d3a3d289b..c0562f02e3 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -15,7 +15,6 @@ */ import { onMounted, ref, shallowRef, watch, PropType, onUnmounted } from 'vue'; import { Chart } from 'chart.js'; -import 'chartjs-adapter-date-fns'; import { enUS } from 'date-fns/locale'; import gradient from 'chartjs-plugin-gradient'; import * as os from '@/os'; diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue index fff93ef965..ec7f49beee 100644 --- a/packages/frontend/src/components/MkHeatmap.vue +++ b/packages/frontend/src/components/MkHeatmap.vue @@ -12,11 +12,10 @@ import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick, w import { Chart } from 'chart.js'; import { enUS } from 'date-fns/locale'; import tinycolor from 'tinycolor2'; +import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; import * as os from '@/os'; -import 'chartjs-adapter-date-fns'; import { defaultStore } from '@/store'; import { useChartTooltip } from '@/scripts/use-chart-tooltip'; -import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; import { chartVLine } from '@/scripts/chart-vline'; import { alpha } from '@/scripts/color'; import { initChart } from '@/scripts/init-chart'; diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue index 95690bde47..e91b58a4a8 100644 --- a/packages/frontend/src/components/MkRetentionHeatmap.vue +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -12,11 +12,10 @@ import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } import { Chart } from 'chart.js'; import { enUS } from 'date-fns/locale'; import tinycolor from 'tinycolor2'; +import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; import * as os from '@/os'; -import 'chartjs-adapter-date-fns'; import { defaultStore } from '@/store'; import { useChartTooltip } from '@/scripts/use-chart-tooltip'; -import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; import { chartVLine } from '@/scripts/chart-vline'; import { alpha } from '@/scripts/color'; import { initChart } from '@/scripts/init-chart'; diff --git a/packages/frontend/src/pages/admin/overview.active-users.vue b/packages/frontend/src/pages/admin/overview.active-users.vue index e01009b7aa..14b09f34e9 100644 --- a/packages/frontend/src/pages/admin/overview.active-users.vue +++ b/packages/frontend/src/pages/admin/overview.active-users.vue @@ -12,11 +12,10 @@ import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } import { Chart } from 'chart.js'; import { enUS } from 'date-fns/locale'; import tinycolor from 'tinycolor2'; +import gradient from 'chartjs-plugin-gradient'; import * as os from '@/os'; -import 'chartjs-adapter-date-fns'; import { defaultStore } from '@/store'; import { useChartTooltip } from '@/scripts/use-chart-tooltip'; -import gradient from 'chartjs-plugin-gradient'; import { chartVLine } from '@/scripts/chart-vline'; import { alpha } from '@/scripts/color'; import { initChart } from '@/scripts/init-chart'; diff --git a/packages/frontend/src/pages/admin/overview.vue b/packages/frontend/src/pages/admin/overview.vue index c16a928a80..2e0b49c5a3 100644 --- a/packages/frontend/src/pages/admin/overview.vue +++ b/packages/frontend/src/pages/admin/overview.vue @@ -77,7 +77,6 @@ import * as os from '@/os'; import { stream } from '@/stream'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; -import 'chartjs-adapter-date-fns'; import { defaultStore } from '@/store'; import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue'; import MkFolder from '@/components/MkFolder.vue'; diff --git a/packages/frontend/src/pages/user/activity.heatmap.vue b/packages/frontend/src/pages/user/activity.heatmap.vue index 8472f79948..252985d113 100644 --- a/packages/frontend/src/pages/user/activity.heatmap.vue +++ b/packages/frontend/src/pages/user/activity.heatmap.vue @@ -14,7 +14,6 @@ import { enUS } from 'date-fns/locale'; import tinycolor from 'tinycolor2'; import * as misskey from 'misskey-js'; import * as os from '@/os'; -import 'chartjs-adapter-date-fns'; import { defaultStore } from '@/store'; import { useChartTooltip } from '@/scripts/use-chart-tooltip'; import { chartVLine } from '@/scripts/chart-vline'; diff --git a/packages/frontend/src/pages/user/activity.pv.vue b/packages/frontend/src/pages/user/activity.pv.vue index eaaeba976c..2d83d1ddc3 100644 --- a/packages/frontend/src/pages/user/activity.pv.vue +++ b/packages/frontend/src/pages/user/activity.pv.vue @@ -13,11 +13,10 @@ import { Chart } from 'chart.js'; import { enUS } from 'date-fns/locale'; import tinycolor from 'tinycolor2'; import * as misskey from 'misskey-js'; +import gradient from 'chartjs-plugin-gradient'; import * as os from '@/os'; -import 'chartjs-adapter-date-fns'; import { defaultStore } from '@/store'; import { useChartTooltip } from '@/scripts/use-chart-tooltip'; -import gradient from 'chartjs-plugin-gradient'; import { chartVLine } from '@/scripts/chart-vline'; import { alpha } from '@/scripts/color'; import { initChart } from '@/scripts/init-chart'; diff --git a/packages/frontend/src/scripts/init-chart.ts b/packages/frontend/src/scripts/init-chart.ts index 005ac5e271..fc18869009 100644 --- a/packages/frontend/src/scripts/init-chart.ts +++ b/packages/frontend/src/scripts/init-chart.ts @@ -20,6 +20,7 @@ import gradient from 'chartjs-plugin-gradient'; import zoomPlugin from 'chartjs-plugin-zoom'; import { MatrixController, MatrixElement } from 'chartjs-chart-matrix'; import { defaultStore } from '@/store'; +import 'chartjs-adapter-date-fns'; export function initChart() { Chart.register( -- cgit v1.2.3-freya From fb05e86db72740933292f105da3fce43b8fafb1f Mon Sep 17 00:00:00 2001 From: syuilo Date: Tue, 3 Jan 2023 14:08:52 +0900 Subject: :art: --- packages/frontend/src/components/MkChart.vue | 13 ++-- packages/frontend/src/components/MkChartLegend.vue | 75 ++++++++++++++++++++++ packages/frontend/src/pages/user/activity.pv.vue | 15 ++--- packages/frontend/src/scripts/chart-legend.ts | 12 ++++ packages/frontend/src/scripts/chart-vline.ts | 4 +- 5 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 packages/frontend/src/components/MkChartLegend.vue create mode 100644 packages/frontend/src/scripts/chart-legend.ts (limited to 'packages/frontend/src/scripts') diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index c0562f02e3..ea28cfa794 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -1,6 +1,7 @@ @@ -20,6 +21,8 @@ import { useChartTooltip } from '@/scripts/use-chart-tooltip'; import { chartVLine } from '@/scripts/chart-vline'; import { alpha } from '@/scripts/color'; import { initChart } from '@/scripts/init-chart'; +import { chartLegend } from '@/scripts/chart-legend'; +import MkChartLegend from '@/components/MkChartLegend.vue'; initChart(); @@ -28,6 +31,7 @@ const props = defineProps<{ }>(); const chartEl = $shallowRef(null); +let legendEl = $shallowRef>(); const now = new Date(); let chartInstance: Chart = null; const chartLimit = 30; @@ -153,14 +157,7 @@ async function renderChart() { }, }, legend: { - display: true, - position: 'bottom', - padding: { - left: 0, - right: 0, - top: 8, - bottom: 0, - }, + display: false, }, tooltip: { enabled: false, @@ -173,7 +170,7 @@ async function renderChart() { gradient, }, }, - plugins: [chartVLine(vLineColor)], + plugins: [chartVLine(vLineColor), chartLegend(legendEl)], }); fetching = false; diff --git a/packages/frontend/src/scripts/chart-legend.ts b/packages/frontend/src/scripts/chart-legend.ts new file mode 100644 index 0000000000..6a5370cc87 --- /dev/null +++ b/packages/frontend/src/scripts/chart-legend.ts @@ -0,0 +1,12 @@ +import { Plugin } from 'chart.js'; +import MkChartLegend from '@/components/MkChartLegend.vue'; + +export const chartLegend = (legend: InstanceType) => ({ + id: 'htmlLegend', + afterUpdate(chart, args, options) { + // Reuse the built-in legendItems generator + const items = chart.options.plugins.legend.labels.generateLabels(chart); + + legend.update(chart, items); + }, +}) as Plugin; diff --git a/packages/frontend/src/scripts/chart-vline.ts b/packages/frontend/src/scripts/chart-vline.ts index 10021583e0..f321443834 100644 --- a/packages/frontend/src/scripts/chart-vline.ts +++ b/packages/frontend/src/scripts/chart-vline.ts @@ -1,3 +1,5 @@ +import { Plugin } from 'chart.js'; + export const chartVLine = (vLineColor: string) => ({ id: 'vLine', beforeDraw(chart, args, options) { @@ -18,4 +20,4 @@ export const chartVLine = (vLineColor: string) => ({ ctx.restore(); } }, -}); +}) as Plugin; -- cgit v1.2.3-freya From e44f83fb9c13e4fab8aee07603632d533d12b4d8 Mon Sep 17 00:00:00 2001 From: syuilo Date: Tue, 3 Jan 2023 15:51:49 +0900 Subject: update AiScript to 0.12.0 --- CHANGELOG.md | 3 ++ packages/backend/package.json | 1 - packages/frontend/package.json | 2 +- packages/frontend/src/components/page/page.vue | 30 +---------- packages/frontend/src/pages/scratchpad.vue | 8 +-- packages/frontend/src/pages/settings/general.vue | 2 - .../frontend/src/pages/settings/plugin.install.vue | 31 ++++++++--- .../src/pages/settings/preferences-backups.vue | 1 - packages/frontend/src/plugin.ts | 15 +++--- packages/frontend/src/scripts/hpml/evaluator.ts | 61 ++-------------------- packages/frontend/src/scripts/hpml/lib.ts | 5 +- packages/frontend/src/store.ts | 4 -- packages/frontend/src/widgets/aiscript.vue | 8 +-- packages/frontend/src/widgets/button.vue | 8 +-- yarn.lock | 49 +++++------------ 15 files changed, 73 insertions(+), 155 deletions(-) (limited to 'packages/frontend/src/scripts') diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b39c66042..284399b55e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ You should also include the user name that made the change. - You may have to `yarn run clean-all`, `sudo corepack enable` and `yarn set version berry` before running `yarn install` if you're still on yarn classic - 新たに動的なPagesを作ることはできなくなりました - 代わりに今後AiScriptを用いてより柔軟に動的なコンテンツを作成できるMisskey Play機能の実装を予定しています。 +- AiScriptが0.12.0にアップデートされました + - 0.12.0の変更点についてはこちら https://github.com/syuilo/aiscript/blob/master/CHANGELOG.md#0120 + - 0.12.0未満のプラグインは読み込むことはできません - iOS15以下のデバイスはサポートされなくなりました - API: カスタム絵文字エンティティに`url`プロパティが含まれなくなりました - 絵文字画像を表示するには、`/emoji/.webp`にリクエストすると画像が返ります。 diff --git a/packages/backend/package.json b/packages/backend/package.json index e4e3274c0b..e78b3ef70a 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -37,7 +37,6 @@ "@nestjs/testing": "9.2.1", "@peertube/http-signature": "1.7.0", "@sinonjs/fake-timers": "10.0.2", - "@syuilo/aiscript": "0.11.1", "accepts": "^1.3.8", "ajv": "8.11.2", "archiver": "5.3.1", diff --git a/packages/frontend/package.json b/packages/frontend/package.json index e380165681..2506e8e9d3 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -11,7 +11,7 @@ "@rollup/plugin-alias": "4.0.2", "@rollup/plugin-json": "6.0.0", "@rollup/pluginutils": "5.0.2", - "@syuilo/aiscript": "0.11.1", + "@syuilo/aiscript": "0.12.0", "@tabler/icons": "^1.118.0", "@vitejs/plugin-vue": "4.0.0", "@vue/compiler-sfc": "3.2.45", diff --git a/packages/frontend/src/components/page/page.vue b/packages/frontend/src/components/page/page.vue index b5cb73c009..87a288befe 100644 --- a/packages/frontend/src/components/page/page.vue +++ b/packages/frontend/src/components/page/page.vue @@ -6,7 +6,6 @@ - diff --git a/packages/frontend/src/widgets/rss.vue b/packages/frontend/src/widgets/rss.vue index c0338c8e47..554413cd1e 100644 --- a/packages/frontend/src/widgets/rss.vue +++ b/packages/frontend/src/widgets/rss.vue @@ -5,19 +5,24 @@
-
- {{ item.title }} +
+ +
{{ i18n.ts.nothing }}
+
+
- -- cgit v1.2.3-freya From ebe340d5105595abe2406e8f386c3ab69703b73b Mon Sep 17 00:00:00 2001 From: syuilo Date: Thu, 5 Jan 2023 13:59:48 +0900 Subject: MisskeyPlay (#9467) * wip * wip * wip * wip * wip * Update ui.ts * wip * wip * wip * wip * wip * wip * wip * wip * Update CHANGELOG.md * wip * wip * wip * wip * :art: * wip * :v: --- CHANGELOG.md | 10 +- locales/ja-JP.yml | 21 + packages/backend/migration/1672822262496-Flash.js | 29 ++ packages/backend/src/core/CoreModule.ts | 12 + .../src/core/entities/FlashEntityService.ts | 55 +++ .../src/core/entities/FlashLikeEntityService.ts | 44 ++ packages/backend/src/di-symbols.ts | 2 + packages/backend/src/models/RepositoryModule.ts | 18 +- packages/backend/src/models/entities/Flash.ts | 60 +++ packages/backend/src/models/entities/FlashLike.ts | 33 ++ packages/backend/src/models/index.ts | 6 + packages/backend/src/postgre.ts | 4 + packages/backend/src/server/api/EndpointsModule.ts | 36 ++ packages/backend/src/server/api/endpoints.ts | 18 + .../src/server/api/endpoints/flash/create.ts | 66 +++ .../src/server/api/endpoints/flash/delete.ts | 56 +++ .../src/server/api/endpoints/flash/featured.ts | 48 ++ .../backend/src/server/api/endpoints/flash/like.ts | 87 ++++ .../src/server/api/endpoints/flash/my-likes.ts | 68 +++ .../backend/src/server/api/endpoints/flash/my.ts | 57 +++ .../backend/src/server/api/endpoints/flash/show.ts | 60 +++ .../src/server/api/endpoints/flash/unlike.ts | 68 +++ .../src/server/api/endpoints/flash/update.ts | 78 +++ packages/frontend/src/components/MkAsUi.vue | 107 +++++ packages/frontend/src/components/MkButton.vue | 40 +- packages/frontend/src/components/MkChartLegend.vue | 2 +- .../frontend/src/components/MkFlashPreview.vue | 112 +++++ packages/frontend/src/components/MkLaunchPad.vue | 2 +- packages/frontend/src/components/MkPagePreview.vue | 19 +- packages/frontend/src/components/form/select.vue | 2 +- packages/frontend/src/navbar.ts | 39 +- packages/frontend/src/pages/flash/flash-edit.vue | 111 +++++ packages/frontend/src/pages/flash/flash-index.vue | 99 ++++ packages/frontend/src/pages/flash/flash.vue | 291 ++++++++++++ packages/frontend/src/pages/page.vue | 18 +- packages/frontend/src/pages/scratchpad.vue | 102 ++-- packages/frontend/src/pages/settings/navbar.vue | 2 +- packages/frontend/src/router.ts | 14 + packages/frontend/src/scripts/aiscript/ui.ts | 526 +++++++++++++++++++++ .../frontend/src/ui/_common_/navbar-for-mobile.vue | 2 +- packages/frontend/src/ui/_common_/navbar.vue | 4 +- packages/frontend/src/ui/classic.header.vue | 2 +- packages/frontend/src/ui/classic.sidebar.vue | 2 +- packages/frontend/src/widgets/aiscript-app.vue | 122 +++++ packages/frontend/src/widgets/index.ts | 2 + 45 files changed, 2464 insertions(+), 92 deletions(-) create mode 100644 packages/backend/migration/1672822262496-Flash.js create mode 100644 packages/backend/src/core/entities/FlashEntityService.ts create mode 100644 packages/backend/src/core/entities/FlashLikeEntityService.ts create mode 100644 packages/backend/src/models/entities/Flash.ts create mode 100644 packages/backend/src/models/entities/FlashLike.ts create mode 100644 packages/backend/src/server/api/endpoints/flash/create.ts create mode 100644 packages/backend/src/server/api/endpoints/flash/delete.ts create mode 100644 packages/backend/src/server/api/endpoints/flash/featured.ts create mode 100644 packages/backend/src/server/api/endpoints/flash/like.ts create mode 100644 packages/backend/src/server/api/endpoints/flash/my-likes.ts create mode 100644 packages/backend/src/server/api/endpoints/flash/my.ts create mode 100644 packages/backend/src/server/api/endpoints/flash/show.ts create mode 100644 packages/backend/src/server/api/endpoints/flash/unlike.ts create mode 100644 packages/backend/src/server/api/endpoints/flash/update.ts create mode 100644 packages/frontend/src/components/MkAsUi.vue create mode 100644 packages/frontend/src/components/MkFlashPreview.vue create mode 100644 packages/frontend/src/pages/flash/flash-edit.vue create mode 100644 packages/frontend/src/pages/flash/flash-index.vue create mode 100644 packages/frontend/src/pages/flash/flash.vue create mode 100644 packages/frontend/src/scripts/aiscript/ui.ts create mode 100644 packages/frontend/src/widgets/aiscript-app.vue (limited to 'packages/frontend/src/scripts') diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d013ce99..005b011592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ You should also include the user name that made the change. - Migrate to Yarn Berry (v3.2.1) @ThatOneCalculator - You may have to `yarn run clean-all`, `sudo corepack enable` and `yarn set version berry` before running `yarn install` if you're still on yarn classic - 新たに動的なPagesを作ることはできなくなりました - - 代わりに今後AiScriptを用いてより柔軟に動的なコンテンツを作成できるMisskey Play機能の実装を予定しています。 + - 代わりにAiScriptを用いてより柔軟に動的なコンテンツを作成できるMisskey Play機能が実装されています。 - AiScriptが0.12.0にアップデートされました - 0.12.0の変更点についてはこちら https://github.com/syuilo/aiscript/blob/master/CHANGELOG.md#0120 - 0.12.0未満のプラグインは読み込むことはできません @@ -33,12 +33,13 @@ You should also include the user name that made the change. - API: `instance`エンティティに`latestStatus`、`lastCommunicatedAt`、`latestRequestSentAt`プロパティが含まれなくなりました ### Improvements -- Push notification of Antenna note @tamaina -- AVIF support @tamaina -- Add Cloudflare Turnstile CAPTCHA support @CyberRex0 +- Misskey Play @syuilo - Introduce retention-rate aggregation @syuilo - Make possible to export favorited notes @syuilo - Add per user pv chart @syuilo +- Push notification of Antenna note @tamaina +- AVIF support @tamaina +- Add Cloudflare Turnstile CAPTCHA support @CyberRex0 - Server: signToActivityPubGet is set to true by default @syuilo - Server: improve syslog performance @syuilo - Server: improve note scoring for featured notes @CyberRex0 @@ -47,6 +48,7 @@ You should also include the user name that made the change. - Server: delete outdated notes of antenna regularly to improve db performance @syuilo - Server: improve activitypub deliver performance @syuilo - Client: use tabler-icons instead of fontawesome to better design @syuilo +- Client: Add AiScript App widget - Client: Add new gabber kick sounds (thanks for noizenecio) - Client: Add link to user RSS feed in profile menu @ssmucny - Client: Compress non-animated PNG files @saschanaz diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index d6a5518196..32bafcd661 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -916,6 +916,10 @@ loggedInAsBot: "Botアカウントでログイン中" tools: "ツール" cannotLoad: "読み込めません" numberOfProfileView: "プロフィール表示回数" +like: "いいね!" +unlike: "いいねを解除" +numberOfLikes: "いいね数" +show: "表示" _sensitiveMediaDetection: description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。" @@ -1348,6 +1352,7 @@ _widgets: jobQueue: "ジョブキュー" serverMetric: "サーバーメトリクス" aiscript: "AiScriptコンソール" + aiscriptApp: "AiScript App" aichan: "藍" userList: "ユーザーリスト" _userList: @@ -1463,6 +1468,22 @@ _timelines: social: "ソーシャル" global: "グローバル" +_play: + new: "Playの作成" + edit: "Playの編集" + created: "Playを作成しました" + updated: "Playを更新しました" + deleted: "Playを削除しました" + pageSetting: "Play設定" + editThisPage: "このPlayを編集" + viewSource: "ソースを表示" + my: "自分のPlay" + liked: "いいねしたPlay" + featured: "人気" + title: "タイトル" + script: "スクリプト" + summary: "説明" + _pages: newPage: "ページの作成" editPage: "ページの編集" diff --git a/packages/backend/migration/1672822262496-Flash.js b/packages/backend/migration/1672822262496-Flash.js new file mode 100644 index 0000000000..6c2338fab2 --- /dev/null +++ b/packages/backend/migration/1672822262496-Flash.js @@ -0,0 +1,29 @@ +export class Flash1672822262496 { + name = 'Flash1672822262496' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "flash" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "title" character varying(256) NOT NULL, "summary" character varying(1024) NOT NULL, "userId" character varying(32) NOT NULL, "script" character varying(16384) NOT NULL, "permissions" character varying(256) array NOT NULL DEFAULT '{}', "likedCount" integer NOT NULL DEFAULT '0', CONSTRAINT "PK_0c01a2c1c5f2266942dd1b3fdbc" PRIMARY KEY ("id")); COMMENT ON COLUMN "flash"."createdAt" IS 'The created date of the Flash.'; COMMENT ON COLUMN "flash"."updatedAt" IS 'The updated date of the Flash.'; COMMENT ON COLUMN "flash"."userId" IS 'The ID of author.'`); + await queryRunner.query(`CREATE INDEX "IDX_149d2e44785707548c82999b01" ON "flash" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_3aa8ea9a8f15214ad91638c0a7" ON "flash" ("updatedAt") `); + await queryRunner.query(`CREATE INDEX "IDX_9b88250fc2fd009b8f1b5623ed" ON "flash" ("userId") `); + await queryRunner.query(`CREATE TABLE "flash_like" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "flashId" character varying(32) NOT NULL, CONSTRAINT "PK_d110109ee310588d63d6183b233" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_60c4af1c19a7a75f1592f93b28" ON "flash_like" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_cfbfeeccb0cbedcd660b17eb07" ON "flash_like" ("userId", "flashId") `); + await queryRunner.query(`ALTER TABLE "flash" ADD CONSTRAINT "FK_9b88250fc2fd009b8f1b5623ed5" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "flash_like" ADD CONSTRAINT "FK_60c4af1c19a7a75f1592f93b287" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "flash_like" ADD CONSTRAINT "FK_6c16fe0e93b7a1951eca624b76a" FOREIGN KEY ("flashId") REFERENCES "flash"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "flash_like" DROP CONSTRAINT "FK_6c16fe0e93b7a1951eca624b76a"`); + await queryRunner.query(`ALTER TABLE "flash_like" DROP CONSTRAINT "FK_60c4af1c19a7a75f1592f93b287"`); + await queryRunner.query(`ALTER TABLE "flash" DROP CONSTRAINT "FK_9b88250fc2fd009b8f1b5623ed5"`); + await queryRunner.query(`DROP INDEX "public"."IDX_cfbfeeccb0cbedcd660b17eb07"`); + await queryRunner.query(`DROP INDEX "public"."IDX_60c4af1c19a7a75f1592f93b28"`); + await queryRunner.query(`DROP TABLE "flash_like"`); + await queryRunner.query(`DROP INDEX "public"."IDX_9b88250fc2fd009b8f1b5623ed"`); + await queryRunner.query(`DROP INDEX "public"."IDX_3aa8ea9a8f15214ad91638c0a7"`); + await queryRunner.query(`DROP INDEX "public"."IDX_149d2e44785707548c82999b01"`); + await queryRunner.query(`DROP TABLE "flash"`); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 7c6d12abf8..2f17fa389a 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -95,6 +95,8 @@ import { UserEntityService } from './entities/UserEntityService.js'; import { UserGroupEntityService } from './entities/UserGroupEntityService.js'; import { UserGroupInvitationEntityService } from './entities/UserGroupInvitationEntityService.js'; import { UserListEntityService } from './entities/UserListEntityService.js'; +import { FlashEntityService } from './entities/FlashEntityService.js'; +import { FlashLikeEntityService } from './entities/FlashLikeEntityService.js'; import { ApAudienceService } from './activitypub/ApAudienceService.js'; import { ApDbResolverService } from './activitypub/ApDbResolverService.js'; import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js'; @@ -216,6 +218,8 @@ const $UserEntityService: Provider = { provide: 'UserEntityService', useExisting const $UserGroupEntityService: Provider = { provide: 'UserGroupEntityService', useExisting: UserGroupEntityService }; const $UserGroupInvitationEntityService: Provider = { provide: 'UserGroupInvitationEntityService', useExisting: UserGroupInvitationEntityService }; const $UserListEntityService: Provider = { provide: 'UserListEntityService', useExisting: UserListEntityService }; +const $FlashEntityService: Provider = { provide: 'FlashEntityService', useExisting: FlashEntityService }; +const $FlashLikeEntityService: Provider = { provide: 'FlashLikeEntityService', useExisting: FlashLikeEntityService }; const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService }; const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService }; @@ -338,6 +342,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserGroupEntityService, UserGroupInvitationEntityService, UserListEntityService, + FlashEntityService, + FlashLikeEntityService, ApAudienceService, ApDbResolverService, ApDeliverManagerService, @@ -455,6 +461,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserGroupEntityService, $UserGroupInvitationEntityService, $UserListEntityService, + $FlashEntityService, + $FlashLikeEntityService, $ApAudienceService, $ApDbResolverService, $ApDeliverManagerService, @@ -572,6 +580,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserGroupEntityService, UserGroupInvitationEntityService, UserListEntityService, + FlashEntityService, + FlashLikeEntityService, ApAudienceService, ApDbResolverService, ApDeliverManagerService, @@ -688,6 +698,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserGroupEntityService, $UserGroupInvitationEntityService, $UserListEntityService, + $FlashEntityService, + $FlashLikeEntityService, $ApAudienceService, $ApDbResolverService, $ApDeliverManagerService, diff --git a/packages/backend/src/core/entities/FlashEntityService.ts b/packages/backend/src/core/entities/FlashEntityService.ts new file mode 100644 index 0000000000..61bd18c04f --- /dev/null +++ b/packages/backend/src/core/entities/FlashEntityService.ts @@ -0,0 +1,55 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { FlashsRepository, FlashLikesRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { Flash } from '@/models/entities/Flash.js'; +import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class FlashEntityService { + constructor( + @Inject(DI.flashsRepository) + private flashsRepository: FlashsRepository, + + @Inject(DI.flashLikesRepository) + private flashLikesRepository: FlashLikesRepository, + + private userEntityService: UserEntityService, + ) { + } + + @bindThis + public async pack( + src: Flash['id'] | Flash, + me?: { id: User['id'] } | null | undefined, + ): Promise> { + const meId = me ? me.id : null; + const flash = typeof src === 'object' ? src : await this.flashsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: flash.id, + createdAt: flash.createdAt.toISOString(), + updatedAt: flash.updatedAt.toISOString(), + userId: flash.userId, + user: this.userEntityService.pack(flash.user ?? flash.userId, me), // { detail: true } すると無限ループするので注意 + title: flash.title, + summary: flash.summary, + script: flash.script, + likedCount: flash.likedCount, + isLiked: meId ? await this.flashLikesRepository.findOneBy({ flashId: flash.id, userId: meId }).then(x => x != null) : undefined, + }); + } + + @bindThis + public packMany( + flashs: Flash[], + me?: { id: User['id'] } | null | undefined, + ) { + return Promise.all(flashs.map(x => this.pack(x, me))); + } +} + diff --git a/packages/backend/src/core/entities/FlashLikeEntityService.ts b/packages/backend/src/core/entities/FlashLikeEntityService.ts new file mode 100644 index 0000000000..dcf12d53ea --- /dev/null +++ b/packages/backend/src/core/entities/FlashLikeEntityService.ts @@ -0,0 +1,44 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { FlashLikesRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { FlashLike } from '@/models/entities/FlashLike.js'; +import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; +import { FlashEntityService } from './FlashEntityService.js'; + +@Injectable() +export class FlashLikeEntityService { + constructor( + @Inject(DI.flashLikesRepository) + private flashLikesRepository: FlashLikesRepository, + + private flashEntityService: FlashEntityService, + ) { + } + + @bindThis + public async pack( + src: FlashLike['id'] | FlashLike, + me?: { id: User['id'] } | null | undefined, + ) { + const like = typeof src === 'object' ? src : await this.flashLikesRepository.findOneByOrFail({ id: src }); + + return { + id: like.id, + flash: await this.flashEntityService.pack(like.flash ?? like.flashId, me), + }; + } + + @bindThis + public packMany( + likes: any[], + me: { id: User['id'] }, + ) { + return Promise.all(likes.map(x => this.pack(x, me))); + } +} + diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index d2a361405f..9719d773ca 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -69,5 +69,7 @@ export const DI = { adsRepository: Symbol('adsRepository'), passwordResetRequestsRepository: Symbol('passwordResetRequestsRepository'), retentionAggregationsRepository: Symbol('retentionAggregationsRepository'), + flashsRepository: Symbol('flashsRepository'), + flashLikesRepository: Symbol('flashLikesRepository'), //#endregion }; diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index e22f0517ca..a5d5a63931 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation } from './index.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -388,6 +388,18 @@ const $retentionAggregationsRepository: Provider = { inject: [DI.db], }; +const $flashsRepository: Provider = { + provide: DI.flashsRepository, + useFactory: (db: DataSource) => db.getRepository(Flash), + inject: [DI.db], +}; + +const $flashLikesRepository: Provider = { + provide: DI.flashLikesRepository, + useFactory: (db: DataSource) => db.getRepository(FlashLike), + inject: [DI.db], +}; + @Module({ imports: [ ], @@ -456,6 +468,8 @@ const $retentionAggregationsRepository: Provider = { $adsRepository, $passwordResetRequestsRepository, $retentionAggregationsRepository, + $flashsRepository, + $flashLikesRepository, ], exports: [ $usersRepository, @@ -522,6 +536,8 @@ const $retentionAggregationsRepository: Provider = { $adsRepository, $passwordResetRequestsRepository, $retentionAggregationsRepository, + $flashsRepository, + $flashLikesRepository, ], }) export class RepositoryModule {} diff --git a/packages/backend/src/models/entities/Flash.ts b/packages/backend/src/models/entities/Flash.ts new file mode 100644 index 0000000000..d9a6ac987c --- /dev/null +++ b/packages/backend/src/models/entities/Flash.ts @@ -0,0 +1,60 @@ +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; +import { DriveFile } from './DriveFile.js'; + +@Entity() +export class Flash { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the Flash.', + }) + public createdAt: Date; + + @Index() + @Column('timestamp with time zone', { + comment: 'The updated date of the Flash.', + }) + public updatedAt: Date; + + @Column('varchar', { + length: 256, + }) + public title: string; + + @Column('varchar', { + length: 1024, + }) + public summary: string; + + @Index() + @Column({ + ...id(), + comment: 'The ID of author.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 16384, + }) + public script: string; + + @Column('varchar', { + length: 256, array: true, default: '{}', + }) + public permissions: string[]; + + @Column('integer', { + default: 0, + }) + public likedCount: number; +} diff --git a/packages/backend/src/models/entities/FlashLike.ts b/packages/backend/src/models/entities/FlashLike.ts new file mode 100644 index 0000000000..81d39191ca --- /dev/null +++ b/packages/backend/src/models/entities/FlashLike.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; +import { Flash } from './Flash.js'; + +@Entity() +@Index(['userId', 'flashId'], { unique: true }) +export class FlashLike { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone') + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public flashId: Flash['id']; + + @ManyToOne(type => Flash, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public flash: Flash | null; +} diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index ca7a7c9e56..b132475747 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -62,6 +62,8 @@ import { UserSecurityKey } from '@/models/entities/UserSecurityKey.js'; import { Webhook } from '@/models/entities/Webhook.js'; import { Channel } from '@/models/entities/Channel.js'; import { RetentionAggregation } from '@/models/entities/RetentionAggregation.js'; +import { Flash } from '@/models/entities/Flash.js'; +import { FlashLike } from '@/models/entities/FlashLike.js'; import type { Repository } from 'typeorm'; export { @@ -129,6 +131,8 @@ export { Webhook, Channel, RetentionAggregation, + Flash, + FlashLike, }; export type AbuseUserReportsRepository = Repository; @@ -195,3 +199,5 @@ export type UserSecurityKeysRepository = Repository; export type WebhooksRepository = Repository; export type ChannelsRepository = Repository; export type RetentionAggregationsRepository = Repository; +export type FlashsRepository = Repository; +export type FlashLikesRepository = Repository; diff --git a/packages/backend/src/postgre.ts b/packages/backend/src/postgre.ts index 4b4490a0c3..4f6b157d80 100644 --- a/packages/backend/src/postgre.ts +++ b/packages/backend/src/postgre.ts @@ -70,6 +70,8 @@ import { UserSecurityKey } from '@/models/entities/UserSecurityKey.js'; import { Webhook } from '@/models/entities/Webhook.js'; import { Channel } from '@/models/entities/Channel.js'; import { RetentionAggregation } from '@/models/entities/RetentionAggregation.js'; +import { Flash } from '@/models/entities/Flash.js'; +import { FlashLike } from '@/models/entities/FlashLike.js'; import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; @@ -184,6 +186,8 @@ export const entities = [ Webhook, UserIp, RetentionAggregation, + Flash, + FlashLike, ...charts, ]; diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 32eff7f312..60beca4f47 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -266,6 +266,15 @@ import * as ep___pages_like from './endpoints/pages/like.js'; import * as ep___pages_show from './endpoints/pages/show.js'; import * as ep___pages_unlike from './endpoints/pages/unlike.js'; import * as ep___pages_update from './endpoints/pages/update.js'; +import * as ep___flash_create from './endpoints/flash/create.js'; +import * as ep___flash_delete from './endpoints/flash/delete.js'; +import * as ep___flash_featured from './endpoints/flash/featured.js'; +import * as ep___flash_like from './endpoints/flash/like.js'; +import * as ep___flash_show from './endpoints/flash/show.js'; +import * as ep___flash_unlike from './endpoints/flash/unlike.js'; +import * as ep___flash_update from './endpoints/flash/update.js'; +import * as ep___flash_my from './endpoints/flash/my.js'; +import * as ep___flash_myLikes from './endpoints/flash/my-likes.js'; import * as ep___ping from './endpoints/ping.js'; import * as ep___pinnedUsers from './endpoints/pinned-users.js'; import * as ep___promo_read from './endpoints/promo/read.js'; @@ -587,6 +596,15 @@ const $pages_like: Provider = { provide: 'ep:pages/like', useClass: ep___pages_l const $pages_show: Provider = { provide: 'ep:pages/show', useClass: ep___pages_show.default }; const $pages_unlike: Provider = { provide: 'ep:pages/unlike', useClass: ep___pages_unlike.default }; const $pages_update: Provider = { provide: 'ep:pages/update', useClass: ep___pages_update.default }; +const $flash_create: Provider = { provide: 'ep:flash/create', useClass: ep___flash_create.default }; +const $flash_delete: Provider = { provide: 'ep:flash/delete', useClass: ep___flash_delete.default }; +const $flash_featured: Provider = { provide: 'ep:flash/featured', useClass: ep___flash_featured.default }; +const $flash_like: Provider = { provide: 'ep:flash/like', useClass: ep___flash_like.default }; +const $flash_show: Provider = { provide: 'ep:flash/show', useClass: ep___flash_show.default }; +const $flash_unlike: Provider = { provide: 'ep:flash/unlike', useClass: ep___flash_unlike.default }; +const $flash_update: Provider = { provide: 'ep:flash/update', useClass: ep___flash_update.default }; +const $flash_my: Provider = { provide: 'ep:flash/my', useClass: ep___flash_my.default }; +const $flash_myLikes: Provider = { provide: 'ep:flash/my-likes', useClass: ep___flash_myLikes.default }; const $ping: Provider = { provide: 'ep:ping', useClass: ep___ping.default }; const $pinnedUsers: Provider = { provide: 'ep:pinned-users', useClass: ep___pinnedUsers.default }; const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_read.default }; @@ -912,6 +930,15 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $pages_show, $pages_unlike, $pages_update, + $flash_create, + $flash_delete, + $flash_featured, + $flash_like, + $flash_show, + $flash_unlike, + $flash_update, + $flash_my, + $flash_myLikes, $ping, $pinnedUsers, $promo_read, @@ -1231,6 +1258,15 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $pages_show, $pages_unlike, $pages_update, + $flash_create, + $flash_delete, + $flash_featured, + $flash_like, + $flash_show, + $flash_unlike, + $flash_update, + $flash_my, + $flash_myLikes, $ping, $pinnedUsers, $promo_read, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 49dc3b224f..d4f8be5b85 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -265,6 +265,15 @@ import * as ep___pages_like from './endpoints/pages/like.js'; import * as ep___pages_show from './endpoints/pages/show.js'; import * as ep___pages_unlike from './endpoints/pages/unlike.js'; import * as ep___pages_update from './endpoints/pages/update.js'; +import * as ep___flash_create from './endpoints/flash/create.js'; +import * as ep___flash_delete from './endpoints/flash/delete.js'; +import * as ep___flash_featured from './endpoints/flash/featured.js'; +import * as ep___flash_like from './endpoints/flash/like.js'; +import * as ep___flash_show from './endpoints/flash/show.js'; +import * as ep___flash_unlike from './endpoints/flash/unlike.js'; +import * as ep___flash_update from './endpoints/flash/update.js'; +import * as ep___flash_my from './endpoints/flash/my.js'; +import * as ep___flash_myLikes from './endpoints/flash/my-likes.js'; import * as ep___ping from './endpoints/ping.js'; import * as ep___pinnedUsers from './endpoints/pinned-users.js'; import * as ep___promo_read from './endpoints/promo/read.js'; @@ -584,6 +593,15 @@ const eps = [ ['pages/show', ep___pages_show], ['pages/unlike', ep___pages_unlike], ['pages/update', ep___pages_update], + ['flash/create', ep___flash_create], + ['flash/delete', ep___flash_delete], + ['flash/featured', ep___flash_featured], + ['flash/like', ep___flash_like], + ['flash/show', ep___flash_show], + ['flash/unlike', ep___flash_unlike], + ['flash/update', ep___flash_update], + ['flash/my', ep___flash_my], + ['flash/my-likes', ep___flash_myLikes], ['ping', ep___ping], ['pinned-users', ep___pinnedUsers], ['promo/read', ep___promo_read], diff --git a/packages/backend/src/server/api/endpoints/flash/create.ts b/packages/backend/src/server/api/endpoints/flash/create.ts new file mode 100644 index 0000000000..a652047d98 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/flash/create.ts @@ -0,0 +1,66 @@ +import ms from 'ms'; +import { Inject, Injectable } from '@nestjs/common'; +import type { DriveFilesRepository, FlashsRepository, PagesRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { Page } from '@/models/entities/Page.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { PageEntityService } from '@/core/entities/PageEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['flash'], + + requireCredential: true, + + kind: 'write:flash', + + limit: { + duration: ms('1hour'), + max: 10, + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + title: { type: 'string' }, + summary: { type: 'string' }, + script: { type: 'string' }, + permissions: { type: 'array', items: { + type: 'string', + } }, + }, + required: ['title', 'summary', 'script', 'permissions'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.flashsRepository) + private flashsRepository: FlashsRepository, + + private flashEntityService: FlashEntityService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const flash = await this.flashsRepository.insert({ + id: this.idService.genId(), + userId: me.id, + createdAt: new Date(), + updatedAt: new Date(), + title: ps.title, + summary: ps.summary, + script: ps.script, + permissions: ps.permissions, + }).then(x => this.flashsRepository.findOneByOrFail(x.identifiers[0])); + + return await this.flashEntityService.pack(flash); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/flash/delete.ts b/packages/backend/src/server/api/endpoints/flash/delete.ts new file mode 100644 index 0000000000..e94ede9f68 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/flash/delete.ts @@ -0,0 +1,56 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { FlashsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['flashs'], + + requireCredential: true, + + kind: 'write:flash', + + errors: { + noSuchFlash: { + message: 'No such flash.', + code: 'NO_SUCH_FLASH', + id: 'de1623ef-bbb3-4289-a71e-14cfa83d9740', + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '1036ad7b-9f92-4fff-89c3-0e50dc941704', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + flashId: { type: 'string', format: 'misskey:id' }, + }, + required: ['flashId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.flashsRepository) + private flashsRepository: FlashsRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const flash = await this.flashsRepository.findOneBy({ id: ps.flashId }); + if (flash == null) { + throw new ApiError(meta.errors.noSuchFlash); + } + if (flash.userId !== me.id) { + throw new ApiError(meta.errors.accessDenied); + } + + await this.flashsRepository.delete(flash.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/flash/featured.ts b/packages/backend/src/server/api/endpoints/flash/featured.ts new file mode 100644 index 0000000000..570aef96d2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/flash/featured.ts @@ -0,0 +1,48 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { FlashsRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['flash'], + + requireCredential: false, + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Flash', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.flashsRepository) + private flashsRepository: FlashsRepository, + + private flashEntityService: FlashEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.flashsRepository.createQueryBuilder('flash') + .andWhere('flash.likedCount > 0') + .orderBy('flash.likedCount', 'DESC'); + + const flashs = await query.take(10).getMany(); + + return await this.flashEntityService.packMany(flashs, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/flash/like.ts b/packages/backend/src/server/api/endpoints/flash/like.ts new file mode 100644 index 0000000000..5581b8ec60 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/flash/like.ts @@ -0,0 +1,87 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { FlashsRepository, FlashLikesRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['flash'], + + requireCredential: true, + + kind: 'write:flash-likes', + + errors: { + noSuchFlash: { + message: 'No such flash.', + code: 'NO_SUCH_FLASH', + id: 'c07c1491-9161-4c5c-9d75-01906f911f73', + }, + + yourFlash: { + message: 'You cannot like your flash.', + code: 'YOUR_FLASH', + id: '3fd8a0e7-5955-4ba9-85bb-bf3e0c30e13b', + }, + + alreadyLiked: { + message: 'The flash has already been liked.', + code: 'ALREADY_LIKED', + id: '010065cf-ad43-40df-8067-abff9f4686e3', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + flashId: { type: 'string', format: 'misskey:id' }, + }, + required: ['flashId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.flashsRepository) + private flashsRepository: FlashsRepository, + + @Inject(DI.flashLikesRepository) + private flashLikesRepository: FlashLikesRepository, + + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const flash = await this.flashsRepository.findOneBy({ id: ps.flashId }); + if (flash == null) { + throw new ApiError(meta.errors.noSuchFlash); + } + + if (flash.userId === me.id) { + throw new ApiError(meta.errors.yourFlash); + } + + // if already liked + const exist = await this.flashLikesRepository.findOneBy({ + flashId: flash.id, + userId: me.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyLiked); + } + + // Create like + await this.flashLikesRepository.insert({ + id: this.idService.genId(), + createdAt: new Date(), + flashId: flash.id, + userId: me.id, + }); + + this.flashsRepository.increment({ id: flash.id }, 'likedCount', 1); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/flash/my-likes.ts b/packages/backend/src/server/api/endpoints/flash/my-likes.ts new file mode 100644 index 0000000000..f7716ea74a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/flash/my-likes.ts @@ -0,0 +1,68 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { FlashLikesRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { FlashLikeEntityService } from '@/core/entities/FlashLikeEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['account', 'flash'], + + requireCredential: true, + + kind: 'read:flash-likes', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + flash: { + type: 'object', + optional: false, nullable: false, + ref: 'Flash', + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.flashLikesRepository) + private flashLikesRepository: FlashLikesRepository, + + private flashLikeEntityService: FlashLikeEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.flashLikesRepository.createQueryBuilder('like'), ps.sinceId, ps.untilId) + .andWhere('like.userId = :meId', { meId: me.id }) + .leftJoinAndSelect('like.flash', 'flash'); + + const likes = await query + .take(ps.limit) + .getMany(); + + return this.flashLikeEntityService.packMany(likes, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/flash/my.ts b/packages/backend/src/server/api/endpoints/flash/my.ts new file mode 100644 index 0000000000..baed7f000f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/flash/my.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { FlashsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['account', 'flash'], + + requireCredential: true, + + kind: 'read:flash', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Flash', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.flashsRepository) + private flashsRepository: FlashsRepository, + + private flashEntityService: FlashEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.flashsRepository.createQueryBuilder('flash'), ps.sinceId, ps.untilId) + .andWhere('flash.userId = :meId', { meId: me.id }); + + const flashs = await query + .take(ps.limit) + .getMany(); + + return await this.flashEntityService.packMany(flashs); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/flash/show.ts b/packages/backend/src/server/api/endpoints/flash/show.ts new file mode 100644 index 0000000000..48114c5a60 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/flash/show.ts @@ -0,0 +1,60 @@ +import { IsNull } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import type { UsersRepository, FlashsRepository } from '@/models/index.js'; +import type { Flash } from '@/models/entities/Flash.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['flashs'], + + requireCredential: false, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'Flash', + }, + + errors: { + noSuchFlash: { + message: 'No such flash.', + code: 'NO_SUCH_FLASH', + id: 'f0d34a1a-d29a-401d-90ba-1982122b5630', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + flashId: { type: 'string', format: 'misskey:id' }, + }, + required: ['flashId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.flashsRepository) + private flashsRepository: FlashsRepository, + + private flashEntityService: FlashEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const flash = await this.flashsRepository.findOneBy({ id: ps.flashId }); + + if (flash == null) { + throw new ApiError(meta.errors.noSuchFlash); + } + + return await this.flashEntityService.pack(flash, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/flash/unlike.ts b/packages/backend/src/server/api/endpoints/flash/unlike.ts new file mode 100644 index 0000000000..b994f5d347 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/flash/unlike.ts @@ -0,0 +1,68 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { FlashsRepository, FlashLikesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['flash'], + + requireCredential: true, + + kind: 'write:flash-likes', + + errors: { + noSuchFlash: { + message: 'No such flash.', + code: 'NO_SUCH_FLASH', + id: 'afe8424a-a69e-432d-a5f2-2f0740c62410', + }, + + notLiked: { + message: 'You have not liked that flash.', + code: 'NOT_LIKED', + id: '755f25a7-9871-4f65-9f34-51eaad9ae0ac', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + flashId: { type: 'string', format: 'misskey:id' }, + }, + required: ['flashId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.flashsRepository) + private flashsRepository: FlashsRepository, + + @Inject(DI.flashLikesRepository) + private flashLikesRepository: FlashLikesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const flash = await this.flashsRepository.findOneBy({ id: ps.flashId }); + if (flash == null) { + throw new ApiError(meta.errors.noSuchFlash); + } + + const exist = await this.flashLikesRepository.findOneBy({ + flashId: flash.id, + userId: me.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notLiked); + } + + // Delete like + await this.flashLikesRepository.delete(exist.id); + + this.flashsRepository.decrement({ id: flash.id }, 'likedCount', 1); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/flash/update.ts b/packages/backend/src/server/api/endpoints/flash/update.ts new file mode 100644 index 0000000000..9ab17a61e8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/flash/update.ts @@ -0,0 +1,78 @@ +import ms from 'ms'; +import { Not } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import type { FlashsRepository, DriveFilesRepository } from '@/models/index.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['flash'], + + requireCredential: true, + + kind: 'write:flash', + + limit: { + duration: ms('1hour'), + max: 300, + }, + + errors: { + noSuchFlash: { + message: 'No such flash.', + code: 'NO_SUCH_FLASH', + id: '611e13d2-309e-419a-a5e4-e0422da39b02', + }, + + accessDenied: { + message: 'Access denied.', + code: 'ACCESS_DENIED', + id: '08e60c88-5948-478e-a132-02ec701d67b2', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + flashId: { type: 'string', format: 'misskey:id' }, + title: { type: 'string' }, + summary: { type: 'string' }, + script: { type: 'string' }, + permissions: { type: 'array', items: { + type: 'string', + } }, + }, + required: ['flashId', 'title', 'summary', 'script', 'permissions'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.flashsRepository) + private flashsRepository: FlashsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + ) { + super(meta, paramDef, async (ps, me) => { + const flash = await this.flashsRepository.findOneBy({ id: ps.flashId }); + if (flash == null) { + throw new ApiError(meta.errors.noSuchFlash); + } + if (flash.userId !== me.id) { + throw new ApiError(meta.errors.accessDenied); + } + + await this.flashsRepository.update(flash.id, { + updatedAt: new Date(), + title: ps.title, + summary: ps.summary, + script: ps.script, + permissions: ps.permissions, + }); + }); + } +} diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue new file mode 100644 index 0000000000..bc1e25957b --- /dev/null +++ b/packages/frontend/src/components/MkAsUi.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index daf47e12d4..f9602de787 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -2,7 +2,7 @@
@@ -207,20 +207,6 @@ definePageMetadata(computed(() => page ? { padding: 16px 0 0 0; border-top: solid 0.5px var(--divider); - > .like { - > .button { - --accent: rgb(241 97 132); - --X8: rgb(241 92 128); - --buttonBg: rgb(216 71 106 / 5%); - --buttonHoverBg: rgb(216 71 106 / 10%); - color: #ff002f; - - ::v-deep(.count) { - margin-left: 0.5em; - } - } - } - > .other { margin-left: auto; diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue index 9db17efc03..7d097fbaaa 100644 --- a/packages/frontend/src/pages/scratchpad.vue +++ b/packages/frontend/src/pages/scratchpad.vue @@ -1,25 +1,34 @@ - diff --git a/packages/frontend/src/widgets/index.ts b/packages/frontend/src/widgets/index.ts index 39826f13c8..3966649da4 100644 --- a/packages/frontend/src/widgets/index.ts +++ b/packages/frontend/src/widgets/index.ts @@ -22,6 +22,7 @@ export default function(app: App) { app.component('MkwInstanceCloud', defineAsyncComponent(() => import('./instance-cloud.vue'))); app.component('MkwButton', defineAsyncComponent(() => import('./button.vue'))); app.component('MkwAiscript', defineAsyncComponent(() => import('./aiscript.vue'))); + app.component('MkwAiscriptApp', defineAsyncComponent(() => import('./aiscript-app.vue'))); app.component('MkwAichan', defineAsyncComponent(() => import('./aichan.vue'))); app.component('MkwUserList', defineAsyncComponent(() => import('./user-list.vue'))); } @@ -48,6 +49,7 @@ export const widgets = [ 'jobQueue', 'button', 'aiscript', + 'aiscriptApp', 'aichan', 'userList', ]; -- cgit v1.2.3-freya From d890383a008b542c46819a1ce98a2646725a10e7 Mon Sep 17 00:00:00 2001 From: syuilo Date: Thu, 5 Jan 2023 17:09:36 +0900 Subject: add Ui:C:folder for AiScript --- packages/frontend/src/components/MkAsUi.vue | 7 ++++++ packages/frontend/src/scripts/aiscript/ui.ts | 33 +++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) (limited to 'packages/frontend/src/scripts') diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index bc1e25957b..ec214f0396 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -33,6 +33,12 @@ {{ c.text }} + + + +
diff --git a/packages/frontend/src/components/MkPlusOneEffect.vue b/packages/frontend/src/components/MkPlusOneEffect.vue new file mode 100644 index 0000000000..6a09669a68 --- /dev/null +++ b/packages/frontend/src/components/MkPlusOneEffect.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/packages/frontend/src/directives/click-anime.ts b/packages/frontend/src/directives/click-anime.ts index 83ec08543f..3d070177bd 100644 --- a/packages/frontend/src/directives/click-anime.ts +++ b/packages/frontend/src/directives/click-anime.ts @@ -12,6 +12,9 @@ export default { target.classList.add('_anime_bounce_standBy'); el.addEventListener('mousedown', () => { + target.classList.remove('_anime_bounce_ready'); + target.classList.remove('_anime_bounce'); + target.classList.add('_anime_bounce_standBy'); target.classList.add('_anime_bounce_ready'); diff --git a/packages/frontend/src/pages/clicker.vue b/packages/frontend/src/pages/clicker.vue new file mode 100644 index 0000000000..082a303e6f --- /dev/null +++ b/packages/frontend/src/pages/clicker.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index 63c753de22..bfa4a3ceab 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -460,6 +460,10 @@ export const routes = [{ path: '/timeline/antenna/:antennaId', component: page(() => import('./pages/antenna-timeline.vue')), loginRequired: true, +}, { + path: '/clicker', + component: page(() => import('./pages/clicker.vue')), + loginRequired: true, }, { name: 'index', path: '/', diff --git a/packages/frontend/src/scripts/clicker-game.ts b/packages/frontend/src/scripts/clicker-game.ts new file mode 100644 index 0000000000..77206cc8e2 --- /dev/null +++ b/packages/frontend/src/scripts/clicker-game.ts @@ -0,0 +1,46 @@ +import { ref, computed } from 'vue'; +import * as os from '@/os'; + +type SaveData = { + gameVersion: number; + cookies: number; + clicked: number; +}; + +export const saveData = ref(); +export const ready = computed(() => saveData.value != null); + +let prev = ''; + +export async function load() { + try { + saveData.value = await os.api('i/registry/get', { + scope: ['clickerGame'], + key: 'saveData', + }); + } catch (err) { + if (err.code === 'NO_SUCH_KEY') { + saveData.value = { + gameVersion: 1, + cookies: 0, + clicked: 0, + }; + save(); + return; + } + throw err; + } +} + +export async function save() { + const current = JSON.stringify(saveData.value); + if (current === prev) return; + + await os.api('i/registry/set', { + scope: ['clickerGame'], + key: 'saveData', + value: saveData.value, + }); + + prev = current; +} diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index dfdf324bcf..c3b22cd9e1 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -41,6 +41,11 @@ export function openInstanceMenu(ev: MouseEvent) { to: '/api-console', text: 'API Console', icon: 'ti ti-terminal-2', + }, { + type: 'link', + to: '/clicker', + text: '🍪👈', + icon: 'ti ti-cookie', }], }, null, { type: 'parent', diff --git a/packages/frontend/src/widgets/clicker.vue b/packages/frontend/src/widgets/clicker.vue new file mode 100644 index 0000000000..77d1777e97 --- /dev/null +++ b/packages/frontend/src/widgets/clicker.vue @@ -0,0 +1,44 @@ + + + diff --git a/packages/frontend/src/widgets/index.ts b/packages/frontend/src/widgets/index.ts index 3966649da4..eba4abd2f7 100644 --- a/packages/frontend/src/widgets/index.ts +++ b/packages/frontend/src/widgets/index.ts @@ -25,6 +25,7 @@ export default function(app: App) { app.component('MkwAiscriptApp', defineAsyncComponent(() => import('./aiscript-app.vue'))); app.component('MkwAichan', defineAsyncComponent(() => import('./aichan.vue'))); app.component('MkwUserList', defineAsyncComponent(() => import('./user-list.vue'))); + app.component('MkwClicker', defineAsyncComponent(() => import('./clicker.vue'))); } export const widgets = [ @@ -52,4 +53,5 @@ export const widgets = [ 'aiscriptApp', 'aichan', 'userList', + 'clicker', ]; -- cgit v1.2.3-freya From dd78ac089c54847b916e54ff746ad915d9d061b7 Mon Sep 17 00:00:00 2001 From: syuilo Date: Sun, 8 Jan 2023 20:42:45 +0900 Subject: :cookie: --- packages/frontend/src/components/MkClickerGame.vue | 2 ++ packages/frontend/src/scripts/clicker-game.ts | 24 +++++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) (limited to 'packages/frontend/src/scripts') diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue index 6d0b2236b4..03736ac5e4 100644 --- a/packages/frontend/src/components/MkClickerGame.vue +++ b/packages/frontend/src/components/MkClickerGame.vue @@ -31,6 +31,8 @@ let prevCookies = $ref(0); function onClick(ev: MouseEvent) { saveData.value!.cookies++; + saveData.value!.totalCookies++; + saveData.value!.totalHandmadeCookies++; saveData.value!.clicked++; const x = ev.clientX; diff --git a/packages/frontend/src/scripts/clicker-game.ts b/packages/frontend/src/scripts/clicker-game.ts index 77206cc8e2..d3b0f9d1e2 100644 --- a/packages/frontend/src/scripts/clicker-game.ts +++ b/packages/frontend/src/scripts/clicker-game.ts @@ -4,7 +4,11 @@ import * as os from '@/os'; type SaveData = { gameVersion: number; cookies: number; + totalCookies: number; + totalHandmadeCookies: number; clicked: number; + achievements: any[]; + facilities: any[]; }; export const saveData = ref(); @@ -21,15 +25,33 @@ export async function load() { } catch (err) { if (err.code === 'NO_SUCH_KEY') { saveData.value = { - gameVersion: 1, + 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() { -- cgit v1.2.3-freya From 616594d3cdfad73dc1450eb1d127405a81721c7e Mon Sep 17 00:00:00 2001 From: syuilo Date: Tue, 10 Jan 2023 11:34:06 +0900 Subject: :art: --- packages/frontend/src/scripts/sticky-sidebar.ts | 2 +- packages/frontend/src/ui/classic.vue | 2 +- packages/frontend/src/ui/universal.widgets.vue | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) (limited to 'packages/frontend/src/scripts') diff --git a/packages/frontend/src/scripts/sticky-sidebar.ts b/packages/frontend/src/scripts/sticky-sidebar.ts index c67b8f37ac..e6e125c6e8 100644 --- a/packages/frontend/src/scripts/sticky-sidebar.ts +++ b/packages/frontend/src/scripts/sticky-sidebar.ts @@ -7,7 +7,7 @@ export class StickySidebar { private isTop = false; private isBottom = false; private offsetTop: number; - private globalHeaderHeight: number = 59; + private globalHeaderHeight = 59; constructor(container: StickySidebar['container'], marginTop = 0, globalHeaderHeight = 0) { this.container = container; diff --git a/packages/frontend/src/ui/classic.vue b/packages/frontend/src/ui/classic.vue index 321a75122b..dab4b9b274 100644 --- a/packages/frontend/src/ui/classic.vue +++ b/packages/frontend/src/ui/classic.vue @@ -17,7 +17,7 @@
- +
diff --git a/packages/frontend/src/ui/universal.widgets.vue b/packages/frontend/src/ui/universal.widgets.vue index 7a37992933..2e557d30ce 100644 --- a/packages/frontend/src/ui/universal.widgets.vue +++ b/packages/frontend/src/ui/universal.widgets.vue @@ -85,7 +85,6 @@ function updateWidgets(thisWidgets) { .root { position: sticky; height: min-content; - min-height: 100vh; box-sizing: border-box; } -- cgit v1.2.3-freya From 6b37c0927409a0b1651b6435405299e2c2394f4d Mon Sep 17 00:00:00 2001 From: tamaina Date: Tue, 10 Jan 2023 13:53:06 +0900 Subject: refactor: api, apiGet関数をosから@/scripts/api.tsに分離する (#9510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * split api? * fix * :v: * no vue split? * Revert "no vue split?" This reverts commit 27ccec971e2925184a58da65c3472655def2617c. --- packages/frontend/src/os.ts | 82 +----------------------------------- packages/frontend/src/scripts/api.ts | 79 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 80 deletions(-) create mode 100644 packages/frontend/src/scripts/api.ts (limited to 'packages/frontend/src/scripts') diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 6e36f18374..15d725defb 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -4,89 +4,11 @@ import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue'; import { EventEmitter } from 'eventemitter3'; import insertTextAtCursor from 'insert-text-at-cursor'; import * as Misskey from 'misskey-js'; -import { apiUrl, url } from '@/config'; import MkPostFormDialog from '@/components/MkPostFormDialog.vue'; import MkWaitingDialog from '@/components/MkWaitingDialog.vue'; import { MenuItem } from '@/types/menu'; -import { $i } from '@/account'; - -export const pendingApiRequestsCount = ref(0); - -const apiClient = new Misskey.api.APIClient({ - origin: url, -}); - -export const api = ((endpoint: string, data: Record = {}, token?: string | null | undefined) => { - pendingApiRequestsCount.value++; - - const onFinally = () => { - pendingApiRequestsCount.value--; - }; - - const promise = new Promise((resolve, reject) => { - // Append a credential - if ($i) (data as any).i = $i.token; - if (token !== undefined) (data as any).i = token; - - // Send request - window.fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, { - method: 'POST', - body: JSON.stringify(data), - credentials: 'omit', - cache: 'no-cache', - headers: { - 'Content-Type': 'application/json', - }, - }).then(async (res) => { - const body = res.status === 204 ? null : await res.json(); - - if (res.status === 200) { - resolve(body); - } else if (res.status === 204) { - resolve(); - } else { - reject(body.error); - } - }).catch(reject); - }); - - promise.then(onFinally, onFinally); - - return promise; -}) as typeof apiClient.request; - -export const apiGet = ((endpoint: string, data: Record = {}) => { - pendingApiRequestsCount.value++; - - const onFinally = () => { - pendingApiRequestsCount.value--; - }; - - const query = new URLSearchParams(data); - - const promise = new Promise((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(); - } else { - reject(body.error); - } - }).catch(reject); - }); - - promise.then(onFinally, onFinally); - - return promise; -}) as typeof apiClient.request; +import { pendingApiRequestsCount, api, apiGet } from '@/scripts/api'; +export { pendingApiRequestsCount, api, apiGet }; export const apiWithDialog = (( endpoint: string, diff --git a/packages/frontend/src/scripts/api.ts b/packages/frontend/src/scripts/api.ts new file mode 100644 index 0000000000..82b1ba3cea --- /dev/null +++ b/packages/frontend/src/scripts/api.ts @@ -0,0 +1,79 @@ +import { Endpoints } from 'misskey-js/built/api.types'; +import { ref } from 'vue'; +import { apiUrl } from '@/config'; +import { $i } from '@/account'; +export const pendingApiRequestsCount = ref(0); + +// Implements Misskey.api.ApiClient.request +export function api(endpoint: E, data: P = {} as any, token?: string | null | undefined): Promise { + pendingApiRequestsCount.value++; + + const onFinally = () => { + pendingApiRequestsCount.value--; + }; + + const promise = new Promise((resolve, reject) => { + // Append a credential + if ($i) (data as any).i = $i.token; + if (token !== undefined) (data as any).i = token; + + // Send request + window.fetch(endpoint.indexOf('://') > -1 ? endpoint : `${apiUrl}/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + credentials: 'omit', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }).catch(reject); + }); + + promise.then(onFinally, onFinally); + + return promise; +}; + +// Implements Misskey.api.ApiClient.request +export function apiGet(endpoint: E, data: P = {} as any): Promise { + pendingApiRequestsCount.value++; + + const onFinally = () => { + pendingApiRequestsCount.value--; + }; + + const query = new URLSearchParams(data as any); + + const promise = new Promise((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(); + } else { + reject(body.error); + } + }).catch(reject); + }); + + promise.then(onFinally, onFinally); + + return promise; +}; -- cgit v1.2.3-freya From af80fee899ba28da51f1c4a36e2ca01be31eb998 Mon Sep 17 00:00:00 2001 From: syuilo Date: Tue, 10 Jan 2023 13:53:41 +0900 Subject: lint --- packages/frontend/src/scripts/api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'packages/frontend/src/scripts') diff --git a/packages/frontend/src/scripts/api.ts b/packages/frontend/src/scripts/api.ts index 82b1ba3cea..f9fd11f069 100644 --- a/packages/frontend/src/scripts/api.ts +++ b/packages/frontend/src/scripts/api.ts @@ -42,7 +42,7 @@ export function api(en promise.then(onFinally, onFinally); return promise; -}; +} // Implements Misskey.api.ApiClient.request export function apiGet(endpoint: E, data: P = {} as any): Promise { @@ -76,4 +76,4 @@ export function apiGet promise.then(onFinally, onFinally); return promise; -}; +} -- cgit v1.2.3-freya From 2470afaa2e200fb2fc748e0f8eef5e2c215369b6 Mon Sep 17 00:00:00 2001 From: syuilo Date: Thu, 12 Jan 2023 21:02:26 +0900 Subject: Role (#9437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * Update CHANGELOG.md * wip * wip * wip * Update create.ts * wip * wip * Update CHANGELOG.md * wip * wip * wip * wip * wip * wip * wip * Update CHANGELOG.md * wip * wip * Update delete.ts * Update delete.ts * wip * wip * wip * Update account-info.vue * wip * wip * Update settings.vue * Update user-info.vue * wip * Update show-file.ts * Update show-user.ts * wip * wip * Update delete.ts * wip * wip * Update overview.moderators.vue * Create 1673500412259-Role.js * wip * wip * Update roles.vue * 色 * Update roles.vue * integrate silence * wip * wip --- CHANGELOG.md | 8 +- locales/ja-JP.yml | 29 +++ packages/backend/migration/1673500412259-Role.js | 37 ++++ .../backend/migration/1673515526953-RoleColor.js | 11 ++ .../backend/migration/1673522856499-RoleIroiro.js | 13 ++ .../migration/1673524604156-RoleLastUsedAt.js | 13 ++ packages/backend/src/core/CoreModule.ts | 12 ++ .../backend/src/core/CreateSystemUserService.ts | 2 +- packages/backend/src/core/DeleteAccountService.ts | 3 + packages/backend/src/core/DriveService.ts | 19 +- packages/backend/src/core/NoteCreateService.ts | 11 +- packages/backend/src/core/RoleService.ts | 201 +++++++++++++++++++++ packages/backend/src/core/SignupService.ts | 6 +- packages/backend/src/core/UserCacheService.ts | 4 +- .../backend/src/core/entities/RoleEntityService.ts | 80 ++++++++ .../backend/src/core/entities/UserEntityService.ts | 19 +- packages/backend/src/di-symbols.ts | 2 + packages/backend/src/models/RepositoryModule.ts | 18 +- packages/backend/src/models/entities/Meta.ts | 21 +-- packages/backend/src/models/entities/Role.ts | 66 +++++++ .../backend/src/models/entities/RoleAssignment.ts | 42 +++++ packages/backend/src/models/entities/User.ts | 22 +-- packages/backend/src/models/index.ts | 6 + packages/backend/src/postgre.ts | 4 + .../backend/src/server/NodeinfoServerService.ts | 7 +- packages/backend/src/server/api/ApiCallService.ts | 57 +++--- packages/backend/src/server/api/EndpointsModule.ts | 52 ++++-- packages/backend/src/server/api/endpoints.ts | 34 ++-- .../server/api/endpoints/admin/accounts/create.ts | 2 +- .../server/api/endpoints/admin/accounts/delete.ts | 10 +- .../endpoints/admin/delete-all-files-of-a-user.ts | 2 +- .../api/endpoints/admin/drive-capacity-override.ts | 61 ------- .../server/api/endpoints/admin/drive/show-file.ts | 9 +- .../server/api/endpoints/admin/get-index-stats.ts | 2 +- .../server/api/endpoints/admin/get-table-stats.ts | 2 +- .../src/server/api/endpoints/admin/get-user-ips.ts | 2 +- .../backend/src/server/api/endpoints/admin/meta.ts | 9 +- .../server/api/endpoints/admin/moderators/add.ts | 49 ----- .../api/endpoints/admin/moderators/remove.ts | 45 ----- .../server/api/endpoints/admin/reset-password.ts | 4 +- .../src/server/api/endpoints/admin/roles/assign.ts | 96 ++++++++++ .../src/server/api/endpoints/admin/roles/create.ts | 75 ++++++++ .../src/server/api/endpoints/admin/roles/delete.ts | 53 ++++++ .../src/server/api/endpoints/admin/roles/list.ts | 39 ++++ .../src/server/api/endpoints/admin/roles/show.ts | 50 +++++ .../server/api/endpoints/admin/roles/unassign.ts | 101 +++++++++++ .../admin/roles/update-default-role-override.ts | 42 +++++ .../src/server/api/endpoints/admin/roles/update.ts | 82 +++++++++ .../src/server/api/endpoints/admin/show-user.ts | 21 ++- .../src/server/api/endpoints/admin/show-users.ts | 26 ++- .../src/server/api/endpoints/admin/silence-user.ts | 55 ------ .../src/server/api/endpoints/admin/suspend-user.ts | 10 +- .../server/api/endpoints/admin/unsilence-user.ts | 51 ------ .../src/server/api/endpoints/admin/update-meta.ts | 15 -- .../src/server/api/endpoints/antennas/create.ts | 4 +- packages/backend/src/server/api/endpoints/drive.ts | 6 +- .../src/server/api/endpoints/drive/files/delete.ts | 4 +- .../src/server/api/endpoints/drive/files/show.ts | 4 +- .../src/server/api/endpoints/drive/files/update.ts | 4 +- packages/backend/src/server/api/endpoints/meta.ts | 20 +- .../src/server/api/endpoints/notes/delete.ts | 6 +- .../server/api/endpoints/notes/global-timeline.ts | 10 +- .../server/api/endpoints/notes/hybrid-timeline.ts | 6 +- .../server/api/endpoints/notes/local-timeline.ts | 10 +- packages/backend/src/server/api/endpoints/users.ts | 5 +- .../src/server/api/endpoints/users/report-abuse.ts | 14 +- .../backend/src/server/api/endpoints/users/show.ts | 8 +- .../server/api/stream/channels/global-timeline.ts | 10 +- .../server/api/stream/channels/hybrid-timeline.ts | 8 +- .../server/api/stream/channels/local-timeline.ts | 10 +- packages/backend/src/server/api/stream/types.ts | 34 ++-- .../backend/src/server/web/ClientServerService.ts | 9 +- packages/backend/src/server/web/boot.js | 4 + packages/frontend/src/components/MkRolePreview.vue | 32 ++++ .../frontend/src/components/MkUserCardMini.vue | 2 +- packages/frontend/src/directives/adaptive-bg.ts | 24 +++ packages/frontend/src/directives/index.ts | 2 + packages/frontend/src/pages/admin/index.vue | 5 + packages/frontend/src/pages/admin/roles.edit.vue | 65 +++++++ packages/frontend/src/pages/admin/roles.editor.vue | 193 ++++++++++++++++++++ packages/frontend/src/pages/admin/roles.role.vue | 121 +++++++++++++ packages/frontend/src/pages/admin/roles.vue | 115 ++++++++++++ packages/frontend/src/pages/admin/settings.vue | 35 +--- packages/frontend/src/pages/admin/users.vue | 1 - packages/frontend/src/pages/timeline.vue | 4 +- packages/frontend/src/pages/user-info.vue | 121 +++++++------ packages/frontend/src/pages/user/home.vue | 4 +- packages/frontend/src/router.ts | 17 ++ packages/frontend/src/scripts/get-user-menu.ts | 32 +--- packages/frontend/src/ui/deck/tl-column.vue | 4 +- 90 files changed, 2027 insertions(+), 638 deletions(-) create mode 100644 packages/backend/migration/1673500412259-Role.js create mode 100644 packages/backend/migration/1673515526953-RoleColor.js create mode 100644 packages/backend/migration/1673522856499-RoleIroiro.js create mode 100644 packages/backend/migration/1673524604156-RoleLastUsedAt.js create mode 100644 packages/backend/src/core/RoleService.ts create mode 100644 packages/backend/src/core/entities/RoleEntityService.ts create mode 100644 packages/backend/src/models/entities/Role.ts create mode 100644 packages/backend/src/models/entities/RoleAssignment.ts delete mode 100644 packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts delete mode 100644 packages/backend/src/server/api/endpoints/admin/moderators/add.ts delete mode 100644 packages/backend/src/server/api/endpoints/admin/moderators/remove.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/roles/assign.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/roles/create.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/roles/delete.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/roles/list.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/roles/show.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/roles/unassign.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/roles/update-default-role-override.ts create mode 100644 packages/backend/src/server/api/endpoints/admin/roles/update.ts delete mode 100644 packages/backend/src/server/api/endpoints/admin/silence-user.ts delete mode 100644 packages/backend/src/server/api/endpoints/admin/unsilence-user.ts create mode 100644 packages/frontend/src/components/MkRolePreview.vue create mode 100644 packages/frontend/src/directives/adaptive-bg.ts create mode 100644 packages/frontend/src/pages/admin/roles.edit.vue create mode 100644 packages/frontend/src/pages/admin/roles.editor.vue create mode 100644 packages/frontend/src/pages/admin/roles.role.vue create mode 100644 packages/frontend/src/pages/admin/roles.vue (limited to 'packages/frontend/src/scripts') diff --git a/CHANGELOG.md b/CHANGELOG.md index 066faa7e05..256df462d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ You should also include the user name that made the change. ## 13.0.0 (unreleased) ### TL;DR -- New features (Play, new widgets, new charts, 🍪👈, etc) +- New features (Role system, Misskey Play, New widgets, New charts, 🍪👈, etc) - Rewriten backend - Better performance (backend and frontend) - Various usability improvements @@ -27,6 +27,11 @@ You should also include the user name that made the change. - 代わりに今後任意の検索プロバイダを設定できる仕組みを構想しています。その仕組みを使えば今まで通りElasticsearchも利用できます - Migrate to Yarn Berry (v3.2.1) @ThatOneCalculator - You may have to `yarn run clean-all`, `sudo corepack enable` and `yarn set version berry` before running `yarn install` if you're still on yarn classic +- 従来のモデレーターフラグは廃止され、より高度なロール機能が導入されました + - これに伴い、アップデートを行うと全てのモデレーターフラグは失われます。そのため、予めモデレーター一覧を記録しておき、アップデート後にモデレーターロールを作りアサインし直してください。 + - サイレンスはロールに統合されました + - ユーザーごとのドライブ容量設定はロールに統合されました + - LTL/GTLの解放状態はロールに統合されました #### For users - ノートのウォッチ機能が削除されました @@ -52,6 +57,7 @@ You should also include the user name that made the change. - API: `instance`エンティティに`latestStatus`、`lastCommunicatedAt`、`latestRequestSentAt`プロパティが含まれなくなりました ### Improvements +- Role system @syuilo - Misskey Play @syuilo - Introduce retention-rate aggregation @syuilo - Make possible to export favorited notes @syuilo diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index aac89d8fe4..3dd770c60f 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -924,6 +924,35 @@ neverShow: "今後表示しない" remindMeLater: "また後で" didYouLikeMisskey: "Misskeyを気に入っていただけましたか?" pleaseDonate: "Misskeyは{host}が使用している無料のソフトウェアです。これからも開発を続けられるように、ぜひ寄付をお願いします!" +roles: "ロール" +role: "ロール" +noramlUser: "一般ユーザー" +undefined: "未定義" +assign: "アサイン" +unassign: "アサインを解除" +color: "色" + +_role: + new: "ロールの作成" + edit: "ロールの編集" + name: "ロール名" + description: "ロールの説明" + type: "ロールの種類" + descriptionOfType: "モデレーターは基本的なモデレーションに関する操作を行えます。\n管理者はインスタンスの全ての設定を変更できます。" + isPublic: "ロールを公開" + descriptionOfIsPublic: "ロールにアサインされたユーザーを誰でも見ることができます。また、ユーザーのプロフィールでこのロールが表示されます。" + options: "オプション" + baseRole: "ベースロール" + useBaseValue: "ベースロールの値を使用" + chooseRoleToAssign: "アサインするロールを選択" + canEditMembersByModerator: "モデレーターのメンバー編集を許可" + descriptionOfCanEditMembersByModerator: "オンにすると、管理者に加えてモデレーターもこのロールへユーザーをアサイン/アサイン解除できるようになります。オフにすると管理者のみが行えます。" + _options: + gtlAvailable: "グローバルタイムラインの閲覧" + ltlAvailable: "ローカルタイムラインの閲覧" + canPublicNote: "パブリック投稿の許可" + driveCapacity: "ドライブ容量" + antennaMax: "アンテナの作成可能数" _sensitiveMediaDetection: description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。" diff --git a/packages/backend/migration/1673500412259-Role.js b/packages/backend/migration/1673500412259-Role.js new file mode 100644 index 0000000000..a8acedf5b7 --- /dev/null +++ b/packages/backend/migration/1673500412259-Role.js @@ -0,0 +1,37 @@ +export class Role1673500412259 { + name = 'Role1673500412259' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "role" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(256) NOT NULL, "description" character varying(1024) NOT NULL, "isPublic" boolean NOT NULL DEFAULT false, "isModerator" boolean NOT NULL DEFAULT false, "isAdministrator" boolean NOT NULL DEFAULT false, "options" jsonb NOT NULL DEFAULT '{}', CONSTRAINT "PK_b36bcfe02fc8de3c57a8b2391c2" PRIMARY KEY ("id")); COMMENT ON COLUMN "role"."createdAt" IS 'The created date of the Role.'; COMMENT ON COLUMN "role"."updatedAt" IS 'The updated date of the Role.'`); + await queryRunner.query(`CREATE TABLE "role_assignment" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "roleId" character varying(32) NOT NULL, CONSTRAINT "PK_7e79671a8a5db18936173148cb4" PRIMARY KEY ("id")); COMMENT ON COLUMN "role_assignment"."createdAt" IS 'The created date of the RoleAssignment.'; COMMENT ON COLUMN "role_assignment"."userId" IS 'The user ID.'; COMMENT ON COLUMN "role_assignment"."roleId" IS 'The role ID.'`); + await queryRunner.query(`CREATE INDEX "IDX_db5b72c16227c97ca88734d5c2" ON "role_assignment" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_f0de67fd09cd3cd0aabca79994" ON "role_assignment" ("roleId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_0953deda7ce6e1448e935859e5" ON "role_assignment" ("userId", "roleId") `); + await queryRunner.query(`ALTER TABLE "user" RENAME COLUMN "isAdmin" TO "isRoot"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isModerator"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "driveCapacityOverrideMb"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "disableLocalTimeline"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "disableGlobalTimeline"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "localDriveCapacityMb"`); + await queryRunner.query(`ALTER TABLE "meta" ADD "defaultRoleOverride" jsonb NOT NULL DEFAULT '{}'`); + await queryRunner.query(`ALTER TABLE "role_assignment" ADD CONSTRAINT "FK_db5b72c16227c97ca88734d5c2b" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "role_assignment" ADD CONSTRAINT "FK_f0de67fd09cd3cd0aabca79994d" FOREIGN KEY ("roleId") REFERENCES "role"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "role_assignment" DROP CONSTRAINT "FK_f0de67fd09cd3cd0aabca79994d"`); + await queryRunner.query(`ALTER TABLE "role_assignment" DROP CONSTRAINT "FK_db5b72c16227c97ca88734d5c2b"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "defaultRoleOverride"`); + await queryRunner.query(`ALTER TABLE "meta" ADD "localDriveCapacityMb" integer NOT NULL DEFAULT '1024'`); + await queryRunner.query(`ALTER TABLE "meta" ADD "disableGlobalTimeline" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "meta" ADD "disableLocalTimeline" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "user" ADD "driveCapacityOverrideMb" integer`); + await queryRunner.query(`ALTER TABLE "user" ADD "isModerator" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "user" RENAME COLUMN "isRoot" TO "isAdmin"`); + await queryRunner.query(`DROP INDEX "public"."IDX_0953deda7ce6e1448e935859e5"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f0de67fd09cd3cd0aabca79994"`); + await queryRunner.query(`DROP INDEX "public"."IDX_db5b72c16227c97ca88734d5c2"`); + await queryRunner.query(`DROP TABLE "role_assignment"`); + await queryRunner.query(`DROP TABLE "role"`); + } +} diff --git a/packages/backend/migration/1673515526953-RoleColor.js b/packages/backend/migration/1673515526953-RoleColor.js new file mode 100644 index 0000000000..343eedf346 --- /dev/null +++ b/packages/backend/migration/1673515526953-RoleColor.js @@ -0,0 +1,11 @@ +export class RoleColor1673515526953 { + name = 'RoleColor1673515526953' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" ADD "color" character varying(256)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "color"`); + } +} diff --git a/packages/backend/migration/1673522856499-RoleIroiro.js b/packages/backend/migration/1673522856499-RoleIroiro.js new file mode 100644 index 0000000000..a1e64d49fe --- /dev/null +++ b/packages/backend/migration/1673522856499-RoleIroiro.js @@ -0,0 +1,13 @@ +export class RoleIroiro1673522856499 { + name = 'RoleIroiro1673522856499' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isSilenced"`); + await queryRunner.query(`ALTER TABLE "role" ADD "canEditMembersByModerator" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "canEditMembersByModerator"`); + await queryRunner.query(`ALTER TABLE "user" ADD "isSilenced" boolean NOT NULL DEFAULT false`); + } +} diff --git a/packages/backend/migration/1673524604156-RoleLastUsedAt.js b/packages/backend/migration/1673524604156-RoleLastUsedAt.js new file mode 100644 index 0000000000..786ef07f5e --- /dev/null +++ b/packages/backend/migration/1673524604156-RoleLastUsedAt.js @@ -0,0 +1,13 @@ +export class RoleLastUsedAt1673524604156 { + name = 'RoleLastUsedAt1673524604156' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "role" ADD "lastUsedAt" TIMESTAMP WITH TIME ZONE NOT NULL`); + await queryRunner.query(`COMMENT ON COLUMN "role"."lastUsedAt" IS 'The last used date of the Role.'`); + } + + async down(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "role"."lastUsedAt" IS 'The last used date of the Role.'`); + await queryRunner.query(`ALTER TABLE "role" DROP COLUMN "lastUsedAt"`); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 2f17fa389a..0ae1ee32b2 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -35,6 +35,7 @@ import { PushNotificationService } from './PushNotificationService.js'; import { QueryService } from './QueryService.js'; import { ReactionService } from './ReactionService.js'; import { RelayService } from './RelayService.js'; +import { RoleService } from './RoleService.js'; import { S3Service } from './S3Service.js'; import { SignupService } from './SignupService.js'; import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js'; @@ -97,6 +98,7 @@ import { UserGroupInvitationEntityService } from './entities/UserGroupInvitation import { UserListEntityService } from './entities/UserListEntityService.js'; import { FlashEntityService } from './entities/FlashEntityService.js'; import { FlashLikeEntityService } from './entities/FlashLikeEntityService.js'; +import { RoleEntityService } from './entities/RoleEntityService.js'; import { ApAudienceService } from './activitypub/ApAudienceService.js'; import { ApDbResolverService } from './activitypub/ApDbResolverService.js'; import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js'; @@ -158,6 +160,7 @@ const $PushNotificationService: Provider = { provide: 'PushNotificationService', const $QueryService: Provider = { provide: 'QueryService', useExisting: QueryService }; const $ReactionService: Provider = { provide: 'ReactionService', useExisting: ReactionService }; const $RelayService: Provider = { provide: 'RelayService', useExisting: RelayService }; +const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService }; const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service }; const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService }; const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useExisting: TwoFactorAuthenticationService }; @@ -220,6 +223,7 @@ const $UserGroupInvitationEntityService: Provider = { provide: 'UserGroupInvitat const $UserListEntityService: Provider = { provide: 'UserListEntityService', useExisting: UserListEntityService }; const $FlashEntityService: Provider = { provide: 'FlashEntityService', useExisting: FlashEntityService }; const $FlashLikeEntityService: Provider = { provide: 'FlashLikeEntityService', useExisting: FlashLikeEntityService }; +const $RoleEntityService: Provider = { provide: 'RoleEntityService', useExisting: RoleEntityService }; const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService }; const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService }; @@ -283,6 +287,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting QueryService, ReactionService, RelayService, + RoleService, S3Service, SignupService, TwoFactorAuthenticationService, @@ -344,6 +349,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserListEntityService, FlashEntityService, FlashLikeEntityService, + RoleEntityService, ApAudienceService, ApDbResolverService, ApDeliverManagerService, @@ -402,6 +408,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $QueryService, $ReactionService, $RelayService, + $RoleService, $S3Service, $SignupService, $TwoFactorAuthenticationService, @@ -463,6 +470,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserListEntityService, $FlashEntityService, $FlashLikeEntityService, + $RoleEntityService, $ApAudienceService, $ApDbResolverService, $ApDeliverManagerService, @@ -522,6 +530,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting QueryService, ReactionService, RelayService, + RoleService, S3Service, SignupService, TwoFactorAuthenticationService, @@ -582,6 +591,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting UserListEntityService, FlashEntityService, FlashLikeEntityService, + RoleEntityService, ApAudienceService, ApDbResolverService, ApDeliverManagerService, @@ -640,6 +650,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $QueryService, $ReactionService, $RelayService, + $RoleService, $S3Service, $SignupService, $TwoFactorAuthenticationService, @@ -700,6 +711,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $UserListEntityService, $FlashEntityService, $FlashLikeEntityService, + $RoleEntityService, $ApAudienceService, $ApDbResolverService, $ApDeliverManagerService, diff --git a/packages/backend/src/core/CreateSystemUserService.ts b/packages/backend/src/core/CreateSystemUserService.ts index 1e753f65cc..8f887d90f9 100644 --- a/packages/backend/src/core/CreateSystemUserService.ts +++ b/packages/backend/src/core/CreateSystemUserService.ts @@ -53,7 +53,7 @@ export class CreateSystemUserService { usernameLower: username.toLowerCase(), host: null, token: secret, - isAdmin: false, + isRoot: false, isLocked: true, isExplorable: false, isBot: true, diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts index e42c738707..0ac12857c9 100644 --- a/packages/backend/src/core/DeleteAccountService.ts +++ b/packages/backend/src/core/DeleteAccountService.ts @@ -23,6 +23,9 @@ export class DeleteAccountService { id: string; host: string | null; }): Promise { + const _user = await this.usersRepository.findOneByOrFail({ id: user.id }); + if (_user.isRoot) throw new Error('cannot delete a root account'); + // 物理削除する前にDelete activityを送信する await this.userSuspendService.doPostSuspend(user).catch(e => {}); diff --git a/packages/backend/src/core/DriveService.ts b/packages/backend/src/core/DriveService.ts index bbdb5fae83..5954abba91 100644 --- a/packages/backend/src/core/DriveService.ts +++ b/packages/backend/src/core/DriveService.ts @@ -32,11 +32,12 @@ import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.j import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { FileInfoService } from '@/core/FileInfoService.js'; import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; import type S3 from 'aws-sdk/clients/s3.js'; type AddFileArgs = { /** User who wish to add file */ - user: { id: User['id']; host: User['host']; driveCapacityOverrideMb: User['driveCapacityOverrideMb'] } | null; + user: { id: User['id']; host: User['host'] } | null; /** File path */ path: string; /** Name */ @@ -62,7 +63,7 @@ type AddFileArgs = { type UploadFromUrlArgs = { url: string; - user: { id: User['id']; host: User['host']; driveCapacityOverrideMb: User['driveCapacityOverrideMb'] } | null; + user: { id: User['id']; host: User['host'] } | null; folderId?: DriveFolder['id'] | null; uri?: string | null; sensitive?: boolean; @@ -106,6 +107,7 @@ export class DriveService { private videoProcessingService: VideoProcessingService, private globalEventService: GlobalEventService, private queueService: QueueService, + private roleService: RoleService, private driveChart: DriveChart, private perUserDriveChart: PerUserDriveChart, private instanceChart: InstanceChart, @@ -463,15 +465,16 @@ export class DriveService { //#region Check drive usage if (user && !isLink) { const usage = await this.driveFileEntityService.calcDriveUsageOf(user); - const u = await this.usersRepository.findOneBy({ id: user.id }); - const instance = await this.metaService.fetch(); - let driveCapacity = 1024 * 1024 * (this.userEntityService.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb); - - if (this.userEntityService.isLocalUser(user) && u?.driveCapacityOverrideMb != null) { - driveCapacity = 1024 * 1024 * u.driveCapacityOverrideMb; + let driveCapacity: number; + if (this.userEntityService.isLocalUser(user)) { + const role = await this.roleService.getUserRoleOptions(user.id); + driveCapacity = 1024 * 1024 * role.driveCapacityMb; this.registerLogger.debug('drive capacity override applied'); this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`); + } else { + const instance = await this.metaService.fetch(); + driveCapacity = 1024 * 1024 * instance.remoteDriveCapacityMb; } this.registerLogger.debug(`drive usage is ${usage} (max: ${driveCapacity})`); diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 6038840406..1c2add5d64 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -42,6 +42,7 @@ import { NoteReadService } from '@/core/NoteReadService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { bindThis } from '@/decorators.js'; import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import { RoleService } from '@/core/RoleService.js'; const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5); @@ -186,6 +187,7 @@ export class NoteCreateService { private remoteUserResolveService: RemoteUserResolveService, private apDeliverManagerService: ApDeliverManagerService, private apRendererService: ApRendererService, + private roleService: RoleService, private notesChart: NotesChart, private perUserNotesChart: PerUserNotesChart, private activeUsersChart: ActiveUsersChart, @@ -197,7 +199,6 @@ export class NoteCreateService { id: User['id']; username: User['username']; host: User['host']; - isSilenced: User['isSilenced']; createdAt: User['createdAt']; isBot: User['isBot']; }, data: Option, silent = false): Promise { @@ -224,9 +225,10 @@ export class NoteCreateService { if (data.channel != null) data.visibleUsers = []; if (data.channel != null) data.localOnly = true; - // サイレンス - if (user.isSilenced && data.visibility === 'public' && data.channel == null) { - data.visibility = 'home'; + if (data.visibility === 'public' && data.channel == null) { + if ((await this.roleService.getUserRoleOptions(user.id)).canPublicNote) { + data.visibility = 'home'; + } } // Renote対象が「ホームまたは全体」以外の公開範囲ならreject @@ -418,7 +420,6 @@ export class NoteCreateService { id: User['id']; username: User['username']; host: User['host']; - isSilenced: User['isSilenced']; createdAt: User['createdAt']; isBot: User['isBot']; }, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) { diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts new file mode 100644 index 0000000000..6ce7f431ca --- /dev/null +++ b/packages/backend/src/core/RoleService.ts @@ -0,0 +1,201 @@ +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { In } from 'typeorm'; +import type { Role, RoleAssignment, RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; +import { Cache } from '@/misc/cache.js'; +import type { CacheableLocalUser, CacheableUser, ILocalUser, User } from '@/models/entities/User.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import { MetaService } from '@/core/MetaService.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; + +export type RoleOptions = { + gtlAvailable: boolean; + ltlAvailable: boolean; + canPublicNote: boolean; + driveCapacityMb: number; + antennaLimit: number; +}; + +export const DEFAULT_ROLE: RoleOptions = { + gtlAvailable: true, + ltlAvailable: true, + canPublicNote: true, + driveCapacityMb: 100, + antennaLimit: 5, +}; + +@Injectable() +export class RoleService implements OnApplicationShutdown { + private rolesCache: Cache; + private roleAssignmentByUserIdCache: Cache; + + constructor( + @Inject(DI.redisSubscriber) + private redisSubscriber: Redis.Redis, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + @Inject(DI.roleAssignmentsRepository) + private roleAssignmentsRepository: RoleAssignmentsRepository, + + private metaService: MetaService, + ) { + //this.onMessage = this.onMessage.bind(this); + + this.rolesCache = new Cache(Infinity); + this.roleAssignmentByUserIdCache = new Cache(Infinity); + + this.redisSubscriber.on('message', this.onMessage); + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message; + switch (type) { + case 'roleCreated': { + const cached = this.rolesCache.get(null); + if (cached) { + body.createdAt = new Date(body.createdAt); + body.updatedAt = new Date(body.updatedAt); + body.lastUsedAt = new Date(body.lastUsedAt); + cached.push(body); + } + break; + } + case 'roleUpdated': { + const cached = this.rolesCache.get(null); + if (cached) { + const i = cached.findIndex(x => x.id === body.id); + if (i > -1) { + body.createdAt = new Date(body.createdAt); + body.updatedAt = new Date(body.updatedAt); + body.lastUsedAt = new Date(body.lastUsedAt); + cached[i] = body; + } + } + break; + } + case 'roleDeleted': { + const cached = this.rolesCache.get(null); + if (cached) { + this.rolesCache.set(null, cached.filter(x => x.id !== body.id)); + } + break; + } + case 'userRoleAssigned': { + const cached = this.roleAssignmentByUserIdCache.get(body.userId); + if (cached) { + body.createdAt = new Date(body.createdAt); + cached.push(body); + } + break; + } + case 'userRoleUnassigned': { + const cached = this.roleAssignmentByUserIdCache.get(body.userId); + if (cached) { + this.roleAssignmentByUserIdCache.set(body.userId, cached.filter(x => x.id !== body.id)); + } + break; + } + default: + break; + } + } + } + + @bindThis + public async getUserRoles(userId: User['id']) { + const assigns = await this.roleAssignmentByUserIdCache.fetch(userId, () => this.roleAssignmentsRepository.findBy({ userId })); + const assignedRoleIds = assigns.map(x => x.roleId); + const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + return roles.filter(r => assignedRoleIds.includes(r.id)); + } + + @bindThis + public async getUserRoleOptions(userId: User['id'] | null): Promise { + const meta = await this.metaService.fetch(); + const baseRoleOptions = { ...DEFAULT_ROLE, ...meta.defaultRoleOverride }; + + if (userId == null) return baseRoleOptions; + + const roles = await this.getUserRoles(userId); + + function getOptionValues(option: keyof RoleOptions) { + if (roles.length === 0) return [baseRoleOptions[option]]; + return roles.map(role => (role.options[option] && (role.options[option].useDefault !== true)) ? role.options[option].value : baseRoleOptions[option]); + } + + return { + gtlAvailable: getOptionValues('gtlAvailable').some(x => x === true), + ltlAvailable: getOptionValues('ltlAvailable').some(x => x === true), + canPublicNote: getOptionValues('canPublicNote').some(x => x === true), + driveCapacityMb: Math.max(...getOptionValues('driveCapacityMb')), + antennaLimit: Math.max(...getOptionValues('antennaLimit')), + }; + } + + @bindThis + public async isModerator(user: { id: User['id']; isRoot: User['isRoot'] } | null): Promise { + if (user == null) return false; + return user.isRoot || (await this.getUserRoles(user.id)).some(r => r.isModerator || r.isAdministrator); + } + + @bindThis + public async isAdministrator(user: { id: User['id']; isRoot: User['isRoot'] } | null): Promise { + if (user == null) return false; + return user.isRoot || (await this.getUserRoles(user.id)).some(r => r.isAdministrator); + } + + @bindThis + public async getModeratorIds(includeAdmins = true): Promise { + const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const moderatorRoles = includeAdmins ? roles.filter(r => r.isModerator || r.isAdministrator) : roles.filter(r => r.isModerator); + const assigns = moderatorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ + roleId: In(moderatorRoles.map(r => r.id)), + }) : []; + // TODO: isRootなアカウントも含める + return assigns.map(a => a.userId); + } + + @bindThis + public async getModerators(includeAdmins = true): Promise { + const ids = await this.getModeratorIds(includeAdmins); + const users = ids.length > 0 ? await this.usersRepository.findBy({ + id: In(ids), + }) : []; + return users; + } + + @bindThis + public async getAdministratorIds(): Promise { + const roles = await this.rolesCache.fetch(null, () => this.rolesRepository.findBy({})); + const administratorRoles = roles.filter(r => r.isAdministrator); + const assigns = administratorRoles.length > 0 ? await this.roleAssignmentsRepository.findBy({ + roleId: In(administratorRoles.map(r => r.id)), + }) : []; + // TODO: isRootなアカウントも含める + return assigns.map(a => a.userId); + } + + @bindThis + public async getAdministrators(): Promise { + const ids = await this.getAdministratorIds(); + const users = ids.length > 0 ? await this.usersRepository.findBy({ + id: In(ids), + }) : []; + return users; + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined) { + this.redisSubscriber.off('message', this.onMessage); + } +} diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts index 9cf203566d..90a7186909 100644 --- a/packages/backend/src/core/SignupService.ts +++ b/packages/backend/src/core/SignupService.ts @@ -11,10 +11,10 @@ import { IdService } from '@/core/IdService.js'; import { UserKeypair } from '@/models/entities/UserKeypair.js'; import { UsedUsername } from '@/models/entities/UsedUsername.js'; import generateUserToken from '@/misc/generate-native-user-token.js'; -import UsersChart from './chart/charts/users.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import { UtilityService } from './UtilityService.js'; import { bindThis } from '@/decorators.js'; +import UsersChart from './chart/charts/users.js'; +import { UtilityService } from './UtilityService.js'; @Injectable() export class SignupService { @@ -112,7 +112,7 @@ export class SignupService { usernameLower: username.toLowerCase(), host: this.utilityService.toPunyNullable(host), token: secret, - isAdmin: (await this.usersRepository.countBy({ + isRoot: (await this.usersRepository.countBy({ host: IsNull(), })) === 0, })); diff --git a/packages/backend/src/core/UserCacheService.ts b/packages/backend/src/core/UserCacheService.ts index 423c8993e3..3f735c0c53 100644 --- a/packages/backend/src/core/UserCacheService.ts +++ b/packages/backend/src/core/UserCacheService.ts @@ -5,8 +5,8 @@ import { Cache } from '@/misc/cache.js'; import type { CacheableLocalUser, CacheableUser, ILocalUser } from '@/models/entities/User.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; -import type { OnApplicationShutdown } from '@nestjs/common'; import { bindThis } from '@/decorators.js'; +import type { OnApplicationShutdown } from '@nestjs/common'; @Injectable() export class UserCacheService implements OnApplicationShutdown { @@ -42,8 +42,6 @@ export class UserCacheService implements OnApplicationShutdown { const { type, body } = obj.message; switch (type) { case 'userChangeSuspendedState': - case 'userChangeSilencedState': - case 'userChangeModeratorState': case 'remoteUserUpdated': { const user = await this.usersRepository.findOneByOrFail({ id: body.id }); this.userByIdCache.set(user.id, user); diff --git a/packages/backend/src/core/entities/RoleEntityService.ts b/packages/backend/src/core/entities/RoleEntityService.ts new file mode 100644 index 0000000000..22c4cdff81 --- /dev/null +++ b/packages/backend/src/core/entities/RoleEntityService.ts @@ -0,0 +1,80 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { User } from '@/models/entities/User.js'; +import type { Role } from '@/models/entities/Role.js'; +import { bindThis } from '@/decorators.js'; +import { DEFAULT_ROLE } from '@/core/RoleService.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class RoleEntityService { + constructor( + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + @Inject(DI.roleAssignmentsRepository) + private roleAssignmentsRepository: RoleAssignmentsRepository, + + private userEntityService: UserEntityService, + ) { + } + + @bindThis + public async pack( + src: Role['id'] | Role, + me?: { id: User['id'] } | null | undefined, + options?: { + detail?: boolean; + }, + ) { + const opts = Object.assign({ + detail: true, + }, options); + + const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src }); + + const assigns = await this.roleAssignmentsRepository.findBy({ + roleId: role.id, + }); + + const roleOptions = { ...role.options }; + for (const [k, v] of Object.entries(DEFAULT_ROLE)) { + if (roleOptions[k] == null) roleOptions[k] = { + useDefault: true, + value: v, + }; + } + + return await awaitAll({ + id: role.id, + createdAt: role.createdAt.toISOString(), + updatedAt: role.updatedAt.toISOString(), + name: role.name, + description: role.description, + color: role.color, + isPublic: role.isPublic, + isAdministrator: role.isAdministrator, + isModerator: role.isModerator, + canEditMembersByModerator: role.canEditMembersByModerator, + options: roleOptions, + ...(opts.detail ? { + users: this.userEntityService.packMany(assigns.map(x => x.userId), me), + } : {}), + }); + } + + @bindThis + public packMany( + roles: any[], + me: { id: User['id'] }, + options?: { + detail?: boolean; + }, + ) { + return Promise.all(roles.map(x => this.pack(x, me, options))); + } +} + diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index a123746220..9a90aec456 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -13,6 +13,8 @@ import type { Instance } from '@/models/entities/Instance.js'; import type { ILocalUser, IRemoteUser, User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, MessagingMessagesRepository, UserGroupJoiningsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository } from '@/models/index.js'; +import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; import type { OnModuleInit } from '@nestjs/common'; import type { AntennaService } from '../AntennaService.js'; import type { CustomEmojiService } from '../CustomEmojiService.js'; @@ -41,7 +43,6 @@ function isRemoteUser(user: T): user is T & { function isRemoteUser(user: User | { host: User['host'] }): boolean { return !isLocalUser(user); } -import { bindThis } from '@/decorators.js'; @Injectable() export class UserEntityService implements OnModuleInit { @@ -50,6 +51,7 @@ export class UserEntityService implements OnModuleInit { private pageEntityService: PageEntityService; private customEmojiService: CustomEmojiService; private antennaService: AntennaService; + private roleService: RoleService; private userInstanceCache: Cache; constructor( @@ -120,6 +122,7 @@ export class UserEntityService implements OnModuleInit { //private pageEntityService: PageEntityService, //private customEmojiService: CustomEmojiService, //private antennaService: AntennaService, + //private roleService: RoleService, ) { this.userInstanceCache = new Cache(1000 * 60 * 60 * 3); } @@ -130,6 +133,7 @@ export class UserEntityService implements OnModuleInit { this.pageEntityService = this.moduleRef.get('PageEntityService'); this.customEmojiService = this.moduleRef.get('CustomEmojiService'); this.antennaService = this.moduleRef.get('AntennaService'); + this.roleService = this.moduleRef.get('RoleService'); } //#region Validators @@ -383,6 +387,9 @@ export class UserEntityService implements OnModuleInit { (profile.ffVisibility === 'followers') && (relation && relation.isFollowing) ? user.followersCount : null; + const isModerator = isMe && opts.detail ? this.roleService.isModerator(user) : null; + const isAdmin = isMe && opts.detail ? this.roleService.isAdministrator(user) : null; + const falsy = opts.detail ? false : undefined; const packed = { @@ -392,8 +399,6 @@ export class UserEntityService implements OnModuleInit { host: user.host, avatarUrl: this.getAvatarUrlSync(user), avatarBlurhash: user.avatar?.blurhash ?? null, - isAdmin: user.isAdmin ?? falsy, - isModerator: user.isModerator ?? falsy, isBot: user.isBot ?? falsy, isCat: user.isCat ?? falsy, instance: user.host ? this.userInstanceCache.fetch(user.host, @@ -418,7 +423,7 @@ export class UserEntityService implements OnModuleInit { bannerUrl: user.banner ? this.driveFileEntityService.getPublicUrl(user.banner, false) : null, bannerBlurhash: user.banner?.blurhash ?? null, isLocked: user.isLocked, - isSilenced: user.isSilenced ?? falsy, + isSilenced: this.roleService.getUserRoleOptions(user.id).then(r => !r.canPublicNote), isSuspended: user.isSuspended ?? falsy, description: profile!.description, location: profile!.location, @@ -443,14 +448,13 @@ export class UserEntityService implements OnModuleInit { userId: user.id, }).then(result => result >= 1) : false, - ...(isMe || opts.includeSecrets ? { - driveCapacityOverrideMb: user.driveCapacityOverrideMb, - } : {}), } : {}), ...(opts.detail && isMe ? { avatarId: user.avatarId, bannerId: user.bannerId, + isModerator: isModerator, + isAdmin: isAdmin, injectFeaturedNote: profile!.injectFeaturedNote, receiveAnnouncementEmail: profile!.receiveAnnouncementEmail, alwaysMarkNsfw: profile!.alwaysMarkNsfw, @@ -484,6 +488,7 @@ export class UserEntityService implements OnModuleInit { } : {}), ...(opts.includeSecrets ? { + role: this.roleService.getUserRoleOptions(user.id), email: profile!.email, emailVerified: profile!.emailVerified, securityKeysList: profile!.twoFactorEnabled diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 9719d773ca..3fb0cd4dae 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -69,6 +69,8 @@ export const DI = { adsRepository: Symbol('adsRepository'), passwordResetRequestsRepository: Symbol('passwordResetRequestsRepository'), retentionAggregationsRepository: Symbol('retentionAggregationsRepository'), + rolesRepository: Symbol('rolesRepository'), + roleAssignmentsRepository: Symbol('roleAssignmentsRepository'), flashsRepository: Symbol('flashsRepository'), flashLikesRepository: Symbol('flashLikesRepository'), //#endregion diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index a5d5a63931..2a235bc6fc 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash } from './index.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -400,6 +400,18 @@ const $flashLikesRepository: Provider = { inject: [DI.db], }; +const $rolesRepository: Provider = { + provide: DI.rolesRepository, + useFactory: (db: DataSource) => db.getRepository(Role), + inject: [DI.db], +}; + +const $roleAssignmentsRepository: Provider = { + provide: DI.roleAssignmentsRepository, + useFactory: (db: DataSource) => db.getRepository(RoleAssignment), + inject: [DI.db], +}; + @Module({ imports: [ ], @@ -468,6 +480,8 @@ const $flashLikesRepository: Provider = { $adsRepository, $passwordResetRequestsRepository, $retentionAggregationsRepository, + $rolesRepository, + $roleAssignmentsRepository, $flashsRepository, $flashLikesRepository, ], @@ -536,6 +550,8 @@ const $flashLikesRepository: Provider = { $adsRepository, $passwordResetRequestsRepository, $retentionAggregationsRepository, + $rolesRepository, + $roleAssignmentsRepository, $flashsRepository, $flashLikesRepository, ], diff --git a/packages/backend/src/models/entities/Meta.ts b/packages/backend/src/models/entities/Meta.ts index fb25e370d2..0d65a8d17a 100644 --- a/packages/backend/src/models/entities/Meta.ts +++ b/packages/backend/src/models/entities/Meta.ts @@ -42,16 +42,6 @@ export class Meta { }) public disableRegistration: boolean; - @Column('boolean', { - default: false, - }) - public disableLocalTimeline: boolean; - - @Column('boolean', { - default: false, - }) - public disableGlobalTimeline: boolean; - @Column('boolean', { default: false, }) @@ -227,12 +217,6 @@ export class Meta { }) public enableSensitiveMediaDetectionForVideos: boolean; - @Column('integer', { - default: 1024, - comment: 'Drive capacity of a local user (MB)', - }) - public localDriveCapacityMb: number; - @Column('integer', { default: 32, comment: 'Drive capacity of a remote user (MB)', @@ -476,4 +460,9 @@ export class Meta { default: true, }) public enableActiveEmailValidation: boolean; + + @Column('jsonb', { + default: { }, + }) + public defaultRoleOverride: Record; } diff --git a/packages/backend/src/models/entities/Role.ts b/packages/backend/src/models/entities/Role.ts new file mode 100644 index 0000000000..34dbc2ce41 --- /dev/null +++ b/packages/backend/src/models/entities/Role.ts @@ -0,0 +1,66 @@ +import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; + +@Entity() +export class Role { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the Role.', + }) + public createdAt: Date; + + @Column('timestamp with time zone', { + comment: 'The updated date of the Role.', + }) + public updatedAt: Date; + + @Column('timestamp with time zone', { + comment: 'The last used date of the Role.', + }) + public lastUsedAt: Date; + + @Column('varchar', { + length: 256, + }) + public name: string; + + @Column('varchar', { + length: 1024, + }) + public description: string; + + @Column('varchar', { + length: 256, nullable: true, + }) + public color: string | null; + + @Column('boolean', { + default: false, + }) + public isPublic: boolean; + + @Column('boolean', { + default: false, + }) + public isModerator: boolean; + + @Column('boolean', { + default: false, + }) + public isAdministrator: boolean; + + @Column('boolean', { + default: false, + }) + public canEditMembersByModerator: boolean; + + @Column('jsonb', { + default: { }, + }) + public options: Record; +} diff --git a/packages/backend/src/models/entities/RoleAssignment.ts b/packages/backend/src/models/entities/RoleAssignment.ts new file mode 100644 index 0000000000..e86f2a8999 --- /dev/null +++ b/packages/backend/src/models/entities/RoleAssignment.ts @@ -0,0 +1,42 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { Role } from './Role.js'; +import { User } from './User.js'; + +@Entity() +@Index(['userId', 'roleId'], { unique: true }) +export class RoleAssignment { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the RoleAssignment.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The user ID.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: 'The role ID.', + }) + public roleId: Role['id']; + + @ManyToOne(type => Role, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public role: Role | null; +} diff --git a/packages/backend/src/models/entities/User.ts b/packages/backend/src/models/entities/User.ts index 73736f0150..8bd5c9700d 100644 --- a/packages/backend/src/models/entities/User.ts +++ b/packages/backend/src/models/entities/User.ts @@ -112,12 +112,6 @@ export class User { }) public isSuspended: boolean; - @Column('boolean', { - default: false, - comment: 'Whether the User is silenced.', - }) - public isSilenced: boolean; - @Column('boolean', { default: false, comment: 'Whether the User is locked.', @@ -138,15 +132,9 @@ export class User { @Column('boolean', { default: false, - comment: 'Whether the User is the admin.', - }) - public isAdmin: boolean; - - @Column('boolean', { - default: false, - comment: 'Whether the User is a moderator.', + comment: 'Whether the User is the root.', }) - public isModerator: boolean; + public isRoot: boolean; @Index() @Column('boolean', { @@ -218,12 +206,6 @@ export class User { }) public token: string | null; - @Column('integer', { - nullable: true, - comment: 'Overrides user drive capacity limit', - }) - public driveCapacityOverrideMb: number | null; - constructor(data: Partial) { if (data == null) return; diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index b132475747..50697597ad 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -62,6 +62,8 @@ import { UserSecurityKey } from '@/models/entities/UserSecurityKey.js'; import { Webhook } from '@/models/entities/Webhook.js'; import { Channel } from '@/models/entities/Channel.js'; import { RetentionAggregation } from '@/models/entities/RetentionAggregation.js'; +import { Role } from '@/models/entities/Role.js'; +import { RoleAssignment } from '@/models/entities/RoleAssignment.js'; import { Flash } from '@/models/entities/Flash.js'; import { FlashLike } from '@/models/entities/FlashLike.js'; import type { Repository } from 'typeorm'; @@ -131,6 +133,8 @@ export { Webhook, Channel, RetentionAggregation, + Role, + RoleAssignment, Flash, FlashLike, }; @@ -199,5 +203,7 @@ export type UserSecurityKeysRepository = Repository; export type WebhooksRepository = Repository; export type ChannelsRepository = Repository; export type RetentionAggregationsRepository = Repository; +export type RolesRepository = Repository; +export type RoleAssignmentsRepository = Repository; export type FlashsRepository = Repository; export type FlashLikesRepository = Repository; diff --git a/packages/backend/src/postgre.ts b/packages/backend/src/postgre.ts index 4f6b157d80..c55cb78a6a 100644 --- a/packages/backend/src/postgre.ts +++ b/packages/backend/src/postgre.ts @@ -70,6 +70,8 @@ import { UserSecurityKey } from '@/models/entities/UserSecurityKey.js'; import { Webhook } from '@/models/entities/Webhook.js'; import { Channel } from '@/models/entities/Channel.js'; import { RetentionAggregation } from '@/models/entities/RetentionAggregation.js'; +import { Role } from '@/models/entities/Role.js'; +import { RoleAssignment } from '@/models/entities/RoleAssignment.js'; import { Flash } from '@/models/entities/Flash.js'; import { FlashLike } from '@/models/entities/FlashLike.js'; @@ -186,6 +188,8 @@ export const entities = [ Webhook, UserIp, RetentionAggregation, + Role, + RoleAssignment, Flash, FlashLike, ...charts, diff --git a/packages/backend/src/server/NodeinfoServerService.ts b/packages/backend/src/server/NodeinfoServerService.ts index c4236c8752..19380d13a4 100644 --- a/packages/backend/src/server/NodeinfoServerService.ts +++ b/packages/backend/src/server/NodeinfoServerService.ts @@ -10,6 +10,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import NotesChart from '@/core/chart/charts/notes.js'; import UsersChart from '@/core/chart/charts/users.js'; +import { DEFAULT_ROLE } from '@/core/RoleService.js'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; const nodeinfo2_1path = '/nodeinfo/2.1'; @@ -73,6 +74,8 @@ export class NodeinfoServerService { const proxyAccount = meta.proxyAccountId ? await this.userEntityService.pack(meta.proxyAccountId).catch(() => null) : null; + const baseRoleOptions = { ...DEFAULT_ROLE, ...meta.defaultRoleOverride }; + return { software: { name: 'misskey', @@ -102,8 +105,8 @@ export class NodeinfoServerService { repositoryUrl: meta.repositoryUrl, feedbackUrl: meta.feedbackUrl, disableRegistration: meta.disableRegistration, - disableLocalTimeline: meta.disableLocalTimeline, - disableGlobalTimeline: meta.disableGlobalTimeline, + disableLocalTimeline: !baseRoleOptions.ltlAvailable, + disableGlobalTimeline: !baseRoleOptions.gtlAvailable, emailRequiredForSignup: meta.emailRequiredForSignup, enableHcaptcha: meta.enableHcaptcha, enableRecaptcha: meta.enableRecaptcha, diff --git a/packages/backend/src/server/api/ApiCallService.ts b/packages/backend/src/server/api/ApiCallService.ts index 68f43c7dfc..415fbf08dd 100644 --- a/packages/backend/src/server/api/ApiCallService.ts +++ b/packages/backend/src/server/api/ApiCallService.ts @@ -12,6 +12,7 @@ import type { UserIpsRepository } from '@/models/index.js'; import { MetaService } from '@/core/MetaService.js'; import { createTemp } from '@/misc/create-temp.js'; import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from './error.js'; import { RateLimiterService } from './RateLimiterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; @@ -41,6 +42,7 @@ export class ApiCallService implements OnApplicationShutdown { private metaService: MetaService, private authenticateService: AuthenticateService, private rateLimiterService: RateLimiterService, + private roleService: RoleService, private apiLoggerService: ApiLoggerService, ) { this.logger = this.apiLoggerService.logger; @@ -202,7 +204,6 @@ export class ApiCallService implements OnApplicationShutdown { request: FastifyRequest<{ Body: Record | undefined, Querystring: Record }>, ) { const isSecure = user != null && token == null; - const isModerator = user != null && (user.isModerator || user.isAdmin); if (ep.meta.secure && !isSecure) { throw new ApiError(accessDenied); @@ -234,30 +235,40 @@ export class ApiCallService implements OnApplicationShutdown { }); } - if (ep.meta.requireCredential && user == null) { - throw new ApiError({ - message: 'Credential required.', - code: 'CREDENTIAL_REQUIRED', - id: '1384574d-a912-4b81-8601-c7b1c4085df1', - httpStatusCode: 401, - }); - } - - if (ep.meta.requireCredential && user!.isSuspended) { - throw new ApiError({ - message: 'Your account has been suspended.', - code: 'YOUR_ACCOUNT_SUSPENDED', - id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370', - httpStatusCode: 403, - }); - } - - if (ep.meta.requireAdmin && !user!.isAdmin) { - throw new ApiError(accessDenied, { reason: 'You are not the admin.' }); + if (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin) { + if (user == null) { + throw new ApiError({ + message: 'Credential required.', + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + httpStatusCode: 401, + }); + } else if (user!.isSuspended) { + throw new ApiError({ + message: 'Your account has been suspended.', + code: 'YOUR_ACCOUNT_SUSPENDED', + id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370', + httpStatusCode: 403, + }); + } } - if (ep.meta.requireModerator && !isModerator) { - throw new ApiError(accessDenied, { reason: 'You are not a moderator.' }); + if ((ep.meta.requireModerator || ep.meta.requireAdmin) && !user!.isRoot) { + const myRoles = await this.roleService.getUserRoles(user!.id); + if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) { + throw new ApiError({ + message: 'You are not assigned to a moderator role.', + code: 'ROLE_PERMISSION_DENIED', + id: 'd33d5333-db36-423d-a8f9-1a2b9549da41', + }); + } + if (ep.meta.requireAdmin && !myRoles.some(r => r.isAdministrator)) { + throw new ApiError({ + message: 'You are not assigned to an administrator role.', + code: 'ROLE_PERMISSION_DENIED', + id: 'c3d38592-54c0-429d-be96-5636b0431a61', + }); + } } if (token && ep.meta.kind && !token.permission.some(p => p === ep.meta.kind)) { diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index ab9349966d..c226c4e93c 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -38,8 +38,6 @@ import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js' import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js'; import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js'; import * as ep___admin_invite from './endpoints/admin/invite.js'; -import * as ep___admin_moderators_add from './endpoints/admin/moderators/add.js'; -import * as ep___admin_moderators_remove from './endpoints/admin/moderators/remove.js'; import * as ep___admin_promo_create from './endpoints/admin/promo/create.js'; import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; @@ -55,13 +53,19 @@ import * as ep___admin_serverInfo from './endpoints/admin/server-info.js'; import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js'; import * as ep___admin_showUser from './endpoints/admin/show-user.js'; import * as ep___admin_showUsers from './endpoints/admin/show-users.js'; -import * as ep___admin_silenceUser from './endpoints/admin/silence-user.js'; import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; -import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-user.js'; import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js'; import * as ep___admin_updateUserNote from './endpoints/admin/update-user-note.js'; +import * as ep___admin_roles_create from './endpoints/admin/roles/create.js'; +import * as ep___admin_roles_delete from './endpoints/admin/roles/delete.js'; +import * as ep___admin_roles_list from './endpoints/admin/roles/list.js'; +import * as ep___admin_roles_show from './endpoints/admin/roles/show.js'; +import * as ep___admin_roles_update from './endpoints/admin/roles/update.js'; +import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js'; +import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'; +import * as ep___admin_roles_updateDefaultRoleOverride from './endpoints/admin/roles/update-default-role-override.js'; import * as ep___announcements from './endpoints/announcements.js'; import * as ep___antennas_create from './endpoints/antennas/create.js'; import * as ep___antennas_delete from './endpoints/antennas/delete.js'; @@ -326,7 +330,6 @@ import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; -import * as ep___admin_driveCapOverride from './endpoints/admin/drive-capacity-override.js'; import * as ep___retention from './endpoints/retention.js'; import { GetterService } from './GetterService.js'; import { ApiLoggerService } from './ApiLoggerService.js'; @@ -369,8 +372,6 @@ const $admin_getIndexStats: Provider = { provide: 'ep:admin/get-index-stats', us const $admin_getTableStats: Provider = { provide: 'ep:admin/get-table-stats', useClass: ep___admin_getTableStats.default }; const $admin_getUserIps: Provider = { provide: 'ep:admin/get-user-ips', useClass: ep___admin_getUserIps.default }; const $admin_invite: Provider = { provide: 'ep:admin/invite', useClass: ep___admin_invite.default }; -const $admin_moderators_add: Provider = { provide: 'ep:admin/moderators/add', useClass: ep___admin_moderators_add.default }; -const $admin_moderators_remove: Provider = { provide: 'ep:admin/moderators/remove', useClass: ep___admin_moderators_remove.default }; const $admin_promo_create: Provider = { provide: 'ep:admin/promo/create', useClass: ep___admin_promo_create.default }; const $admin_queue_clear: Provider = { provide: 'ep:admin/queue/clear', useClass: ep___admin_queue_clear.default }; const $admin_queue_deliverDelayed: Provider = { provide: 'ep:admin/queue/deliver-delayed', useClass: ep___admin_queue_deliverDelayed.default }; @@ -386,13 +387,19 @@ const $admin_serverInfo: Provider = { provide: 'ep:admin/server-info', useClass: const $admin_showModerationLogs: Provider = { provide: 'ep:admin/show-moderation-logs', useClass: ep___admin_showModerationLogs.default }; const $admin_showUser: Provider = { provide: 'ep:admin/show-user', useClass: ep___admin_showUser.default }; const $admin_showUsers: Provider = { provide: 'ep:admin/show-users', useClass: ep___admin_showUsers.default }; -const $admin_silenceUser: Provider = { provide: 'ep:admin/silence-user', useClass: ep___admin_silenceUser.default }; const $admin_suspendUser: Provider = { provide: 'ep:admin/suspend-user', useClass: ep___admin_suspendUser.default }; -const $admin_unsilenceUser: Provider = { provide: 'ep:admin/unsilence-user', useClass: ep___admin_unsilenceUser.default }; const $admin_unsuspendUser: Provider = { provide: 'ep:admin/unsuspend-user', useClass: ep___admin_unsuspendUser.default }; const $admin_updateMeta: Provider = { provide: 'ep:admin/update-meta', useClass: ep___admin_updateMeta.default }; const $admin_deleteAccount: Provider = { provide: 'ep:admin/delete-account', useClass: ep___admin_deleteAccount.default }; const $admin_updateUserNote: Provider = { provide: 'ep:admin/update-user-note', useClass: ep___admin_updateUserNote.default }; +const $admin_roles_create: Provider = { provide: 'ep:admin/roles/create', useClass: ep___admin_roles_create.default }; +const $admin_roles_delete: Provider = { provide: 'ep:admin/roles/delete', useClass: ep___admin_roles_delete.default }; +const $admin_roles_list: Provider = { provide: 'ep:admin/roles/list', useClass: ep___admin_roles_list.default }; +const $admin_roles_show: Provider = { provide: 'ep:admin/roles/show', useClass: ep___admin_roles_show.default }; +const $admin_roles_update: Provider = { provide: 'ep:admin/roles/update', useClass: ep___admin_roles_update.default }; +const $admin_roles_assign: Provider = { provide: 'ep:admin/roles/assign', useClass: ep___admin_roles_assign.default }; +const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', useClass: ep___admin_roles_unassign.default }; +const $admin_roles_updateDefaultRoleOverride: Provider = { provide: 'ep:admin/roles/update-default-role-override', useClass: ep___admin_roles_updateDefaultRoleOverride.default }; const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default }; const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default }; const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default }; @@ -656,7 +663,6 @@ const $users_searchByUsernameAndHost: Provider = { provide: 'ep:users/search-by- const $users_search: Provider = { provide: 'ep:users/search', useClass: ep___users_search.default }; const $users_show: Provider = { provide: 'ep:users/show', useClass: ep___users_show.default }; const $users_stats: Provider = { provide: 'ep:users/stats', useClass: ep___users_stats.default }; -const $admin_driveCapOverride: Provider = { provide: 'ep:admin/drive-capacity-override', useClass: ep___admin_driveCapOverride.default }; const $fetchRss: Provider = { provide: 'ep:fetch-rss', useClass: ep___fetchRss.default }; const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention.default }; @@ -704,8 +710,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_getTableStats, $admin_getUserIps, $admin_invite, - $admin_moderators_add, - $admin_moderators_remove, $admin_promo_create, $admin_queue_clear, $admin_queue_deliverDelayed, @@ -721,13 +725,19 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_showModerationLogs, $admin_showUser, $admin_showUsers, - $admin_silenceUser, $admin_suspendUser, - $admin_unsilenceUser, $admin_unsuspendUser, $admin_updateMeta, $admin_deleteAccount, $admin_updateUserNote, + $admin_roles_create, + $admin_roles_delete, + $admin_roles_list, + $admin_roles_show, + $admin_roles_update, + $admin_roles_assign, + $admin_roles_unassign, + $admin_roles_updateDefaultRoleOverride, $announcements, $antennas_create, $antennas_delete, @@ -991,7 +1001,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_search, $users_show, $users_stats, - $admin_driveCapOverride, $fetchRss, $retention, ], @@ -1033,8 +1042,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_getTableStats, $admin_getUserIps, $admin_invite, - $admin_moderators_add, - $admin_moderators_remove, $admin_promo_create, $admin_queue_clear, $admin_queue_deliverDelayed, @@ -1050,13 +1057,19 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $admin_showModerationLogs, $admin_showUser, $admin_showUsers, - $admin_silenceUser, $admin_suspendUser, - $admin_unsilenceUser, $admin_unsuspendUser, $admin_updateMeta, $admin_deleteAccount, $admin_updateUserNote, + $admin_roles_create, + $admin_roles_delete, + $admin_roles_list, + $admin_roles_show, + $admin_roles_update, + $admin_roles_assign, + $admin_roles_unassign, + $admin_roles_updateDefaultRoleOverride, $announcements, $antennas_create, $antennas_delete, @@ -1318,7 +1331,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $users_search, $users_show, $users_stats, - $admin_driveCapOverride, $fetchRss, $retention, ], diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index f9749ad660..1df3240e41 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -37,8 +37,6 @@ import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js' import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js'; import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js'; import * as ep___admin_invite from './endpoints/admin/invite.js'; -import * as ep___admin_moderators_add from './endpoints/admin/moderators/add.js'; -import * as ep___admin_moderators_remove from './endpoints/admin/moderators/remove.js'; import * as ep___admin_promo_create from './endpoints/admin/promo/create.js'; import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js'; import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js'; @@ -54,13 +52,19 @@ import * as ep___admin_serverInfo from './endpoints/admin/server-info.js'; import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderation-logs.js'; import * as ep___admin_showUser from './endpoints/admin/show-user.js'; import * as ep___admin_showUsers from './endpoints/admin/show-users.js'; -import * as ep___admin_silenceUser from './endpoints/admin/silence-user.js'; import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js'; -import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-user.js'; import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js'; import * as ep___admin_updateUserNote from './endpoints/admin/update-user-note.js'; +import * as ep___admin_roles_create from './endpoints/admin/roles/create.js'; +import * as ep___admin_roles_delete from './endpoints/admin/roles/delete.js'; +import * as ep___admin_roles_list from './endpoints/admin/roles/list.js'; +import * as ep___admin_roles_show from './endpoints/admin/roles/show.js'; +import * as ep___admin_roles_update from './endpoints/admin/roles/update.js'; +import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js'; +import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'; +import * as ep___admin_roles_updateDefaultRoleOverride from './endpoints/admin/roles/update-default-role-override.js'; import * as ep___announcements from './endpoints/announcements.js'; import * as ep___antennas_create from './endpoints/antennas/create.js'; import * as ep___antennas_delete from './endpoints/antennas/delete.js'; @@ -325,7 +329,6 @@ import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_stats from './endpoints/users/stats.js'; import * as ep___fetchRss from './endpoints/fetch-rss.js'; -import * as ep___admin_driveCapOverride from './endpoints/admin/drive-capacity-override.js'; import * as ep___retention from './endpoints/retention.js'; const eps = [ @@ -366,8 +369,6 @@ const eps = [ ['admin/get-table-stats', ep___admin_getTableStats], ['admin/get-user-ips', ep___admin_getUserIps], ['admin/invite', ep___admin_invite], - ['admin/moderators/add', ep___admin_moderators_add], - ['admin/moderators/remove', ep___admin_moderators_remove], ['admin/promo/create', ep___admin_promo_create], ['admin/queue/clear', ep___admin_queue_clear], ['admin/queue/deliver-delayed', ep___admin_queue_deliverDelayed], @@ -383,13 +384,19 @@ const eps = [ ['admin/show-moderation-logs', ep___admin_showModerationLogs], ['admin/show-user', ep___admin_showUser], ['admin/show-users', ep___admin_showUsers], - ['admin/silence-user', ep___admin_silenceUser], ['admin/suspend-user', ep___admin_suspendUser], - ['admin/unsilence-user', ep___admin_unsilenceUser], ['admin/unsuspend-user', ep___admin_unsuspendUser], ['admin/update-meta', ep___admin_updateMeta], ['admin/delete-account', ep___admin_deleteAccount], ['admin/update-user-note', ep___admin_updateUserNote], + ['admin/roles/create', ep___admin_roles_create], + ['admin/roles/delete', ep___admin_roles_delete], + ['admin/roles/list', ep___admin_roles_list], + ['admin/roles/show', ep___admin_roles_show], + ['admin/roles/update', ep___admin_roles_update], + ['admin/roles/assign', ep___admin_roles_assign], + ['admin/roles/unassign', ep___admin_roles_unassign], + ['admin/roles/update-default-role-override', ep___admin_roles_updateDefaultRoleOverride], ['announcements', ep___announcements], ['antennas/create', ep___antennas_create], ['antennas/delete', ep___antennas_delete], @@ -653,7 +660,6 @@ const eps = [ ['users/search', ep___users_search], ['users/show', ep___users_show], ['users/stats', ep___users_stats], - ['admin/drive-capacity-override', ep___admin_driveCapOverride], ['fetch-rss', ep___fetchRss], ['retention', ep___retention], ]; @@ -680,14 +686,14 @@ export interface IEndpointMeta { readonly requireCredential?: boolean; /** - * 管理者のみ使えるエンドポイントか否か + * isModeratorなロールを必要とするか */ - readonly requireAdmin?: boolean; + readonly requireModerator?: boolean; /** - * 管理者またはモデレーターのみ使えるエンドポイントか否か + * isAdministratorなロールを必要とするか */ - readonly requireModerator?: boolean; + readonly requireAdmin?: boolean; /** * エンドポイントのリミテーションに関するやつ diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts index c76ece9e05..bac8ae16e5 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -47,7 +47,7 @@ export default class extends Endpoint { const noUsers = (await this.usersRepository.countBy({ host: IsNull(), })) === 0; - if (!noUsers && !me?.isAdmin) throw new Error('access denied'); + if (!noUsers && !me?.isRoot) throw new Error('access denied'); const { account, secret } = await this.signupService.signup({ username: ps.username, diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts index b7081987ca..e9f72676f0 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts @@ -11,7 +11,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, + requireAdmin: true, } as const; export const paramDef = { @@ -41,12 +41,8 @@ export default class extends Endpoint { throw new Error('user not found'); } - if (user.isAdmin) { - throw new Error('cannot suspend admin'); - } - - if (user.isModerator) { - throw new Error('cannot suspend moderator'); + if (user.isRoot) { + throw new Error('cannot delete a root account'); } if (this.userEntityService.isLocalUser(user)) { diff --git a/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts index 22b78bf19d..c193ed3fb3 100644 --- a/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts @@ -8,7 +8,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireModerator: true, + requireAdmin: true, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts b/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts deleted file mode 100644 index 665e2a8cce..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository } from '@/models/index.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { DI } from '@/di-symbols.js'; -import { UserEntityService } from '@/core/entities/UserEntityService.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: true, - requireModerator: true, -} as const; - -export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - overrideMb: { type: 'number', nullable: true }, - }, - required: ['userId', 'overrideMb'], -} as const; - -// eslint-disable-next-line import/no-default-export -@Injectable() -export default class extends Endpoint { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private userEntityService: UserEntityService, - private moderationLogService: ModerationLogService, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } - - if (!this.userEntityService.isLocalUser(user)) { - throw new Error('user is not local user'); - } - - /*if (user.isAdmin) { - throw new Error('cannot suspend admin'); - } - if (user.isModerator) { - throw new Error('cannot suspend moderator'); - }*/ - - await this.usersRepository.update(user.id, { - driveCapacityOverrideMb: ps.overrideMb, - }); - - this.moderationLogService.insertModerationLog(me, 'change-drive-capacity-override', { - targetId: user.id, - }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts index 6180eeae2b..6376cb153c 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import type { DriveFilesRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -159,6 +160,8 @@ export default class extends Endpoint { constructor( @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, + + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const file = ps.fileId ? await this.driveFilesRepository.findOneBy({ id: ps.fileId }) : await this.driveFilesRepository.findOne({ @@ -175,6 +178,8 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchFile); } + const isModerator = await this.roleService.isModerator(me); + return { id: file.id, userId: file.userId, @@ -202,8 +207,8 @@ export default class extends Endpoint { name: file.name, md5: file.md5, createdAt: file.createdAt.toISOString(), - requestIp: me.isAdmin ? file.requestIp : null, - requestHeaders: me.isAdmin ? file.requestHeaders : null, + requestIp: isModerator ? file.requestIp : null, + requestHeaders: isModerator ? file.requestHeaders : null, }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts b/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts index e53d0bfcea..8ffd2b01e7 100644 --- a/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts +++ b/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts @@ -5,7 +5,7 @@ import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, - requireModerator: true, + requireAdmin: true, tags: ['admin'], } as const; diff --git a/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts b/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts index 41014cb167..09d61bd741 100644 --- a/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts +++ b/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts @@ -5,7 +5,7 @@ import { DI } from '@/di-symbols.js'; export const meta = { requireCredential: true, - requireModerator: true, + requireAdmin: true, tags: ['admin'], diff --git a/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts b/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts index 947a673def..bfcc8a700b 100644 --- a/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts +++ b/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts @@ -7,7 +7,7 @@ export const meta = { tags: ['admin'], requireCredential: true, - requireAdmin: true, + requireModerator: true, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 794ea3d5c9..33f162acf9 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -4,6 +4,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { MetaService } from '@/core/MetaService.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; +import { DEFAULT_ROLE } from '@/core/RoleService.js'; export const meta = { tags: ['meta'], @@ -15,10 +16,6 @@ export const meta = { type: 'object', optional: false, nullable: false, properties: { - driveCapacityPerLocalUserMb: { - type: 'number', - optional: false, nullable: false, - }, driveCapacityPerRemoteUserMb: { type: 'number', optional: false, nullable: false, @@ -377,9 +374,6 @@ export default class extends Endpoint { repositoryUrl: instance.repositoryUrl, feedbackUrl: instance.feedbackUrl, disableRegistration: instance.disableRegistration, - disableLocalTimeline: instance.disableLocalTimeline, - disableGlobalTimeline: instance.disableGlobalTimeline, - driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, emailRequiredForSignup: instance.emailRequiredForSignup, enableHcaptcha: instance.enableHcaptcha, @@ -451,6 +445,7 @@ export default class extends Endpoint { deeplIsPro: instance.deeplIsPro, enableIpLogging: instance.enableIpLogging, enableActiveEmailValidation: instance.enableActiveEmailValidation, + baseRole: { ...DEFAULT_ROLE, ...instance.defaultRoleOverride }, }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/moderators/add.ts b/packages/backend/src/server/api/endpoints/admin/moderators/add.ts deleted file mode 100644 index 2fc5a35e8e..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/moderators/add.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository } from '@/models/index.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: true, - requireAdmin: true, -} as const; - -export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], -} as const; - -// eslint-disable-next-line import/no-default-export -@Injectable() -export default class extends Endpoint { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } - - if (user.isAdmin) { - throw new Error('cannot mark as moderator if admin user'); - } - - await this.usersRepository.update(user.id, { - isModerator: true, - }); - - this.globalEventService.publishInternalEvent('userChangeModeratorState', { id: user.id, isModerator: true }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts b/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts deleted file mode 100644 index f0d7a3f12d..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository } from '@/models/index.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: true, - requireAdmin: true, -} as const; - -export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], -} as const; - -// eslint-disable-next-line import/no-default-export -@Injectable() -export default class extends Endpoint { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } - - await this.usersRepository.update(user.id, { - isModerator: false, - }); - - this.globalEventService.publishInternalEvent('userChangeModeratorState', { id: user.id, isModerator: false }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts index f7d27be9cb..d263f99f6e 100644 --- a/packages/backend/src/server/api/endpoints/admin/reset-password.ts +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -50,8 +50,8 @@ export default class extends Endpoint { throw new Error('user not found'); } - if (user.isAdmin) { - throw new Error('cannot reset password of admin'); + if (user.isRoot) { + throw new Error('cannot reset password of root'); } const passwd = rndstr('a-zA-Z0-9', 8); diff --git a/packages/backend/src/server/api/endpoints/admin/roles/assign.ts b/packages/backend/src/server/api/endpoints/admin/roles/assign.ts new file mode 100644 index 0000000000..7bfb2f6625 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/roles/assign.ts @@ -0,0 +1,96 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { RoleService } from '@/core/RoleService.js'; + +export const meta = { + tags: ['admin', 'role'], + + requireCredential: true, + requireModerator: true, + + errors: { + noSuchRole: { + message: 'No such role.', + code: 'NO_SUCH_ROLE', + id: '6503c040-6af4-4ed9-bf07-f2dd16678eab', + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '558ea170-f653-4700-94d0-5a818371d0df', + }, + + accessDenied: { + message: 'Only administrators can edit members of the role.', + code: 'ACCESS_DENIED', + id: '25b5bc31-dc79-4ebd-9bd2-c84978fd052c', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roleId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + }, + required: [ + 'roleId', + 'userId', + ], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + @Inject(DI.roleAssignmentsRepository) + private roleAssignmentsRepository: RoleAssignmentsRepository, + + private globalEventService: GlobalEventService, + private roleService: RoleService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); + if (role == null) { + throw new ApiError(meta.errors.noSuchRole); + } + + if (!role.canEditMembersByModerator && !(await this.roleService.isAdministrator(me))) { + throw new ApiError(meta.errors.accessDenied); + } + + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const date = new Date(); + const created = await this.roleAssignmentsRepository.insert({ + id: this.idService.genId(), + createdAt: date, + roleId: role.id, + userId: user.id, + }).then(x => this.roleAssignmentsRepository.findOneByOrFail(x.identifiers[0])); + + this.rolesRepository.update(ps.roleId, { + lastUsedAt: new Date(), + }); + + this.globalEventService.publishInternalEvent('userRoleAssigned', created); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/roles/create.ts b/packages/backend/src/server/api/endpoints/admin/roles/create.ts new file mode 100644 index 0000000000..b04188fac6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/roles/create.ts @@ -0,0 +1,75 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RolesRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; +import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; + +export const meta = { + tags: ['admin', 'role'], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string' }, + description: { type: 'string' }, + color: { type: 'string', nullable: true }, + isPublic: { type: 'boolean' }, + isModerator: { type: 'boolean' }, + isAdministrator: { type: 'boolean' }, + canEditMembersByModerator: { type: 'boolean' }, + options: { + type: 'object', + }, + }, + required: [ + 'name', + 'description', + 'color', + 'isPublic', + 'isModerator', + 'isAdministrator', + 'canEditMembersByModerator', + 'options', + ], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + private globalEventService: GlobalEventService, + private idService: IdService, + private roleEntityService: RoleEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const date = new Date(); + const created = await this.rolesRepository.insert({ + id: this.idService.genId(), + createdAt: date, + updatedAt: date, + lastUsedAt: date, + name: ps.name, + description: ps.description, + color: ps.color, + isPublic: ps.isPublic, + isAdministrator: ps.isAdministrator, + isModerator: ps.isModerator, + canEditMembersByModerator: ps.canEditMembersByModerator, + options: ps.options, + }).then(x => this.rolesRepository.findOneByOrFail(x.identifiers[0])); + + this.globalEventService.publishInternalEvent('roleCreated', created); + + return await this.roleEntityService.pack(created, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/roles/delete.ts b/packages/backend/src/server/api/endpoints/admin/roles/delete.ts new file mode 100644 index 0000000000..b56ebdb3ee --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/roles/delete.ts @@ -0,0 +1,53 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RolesRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['admin', 'role'], + + requireCredential: true, + requireAdmin: true, + + errors: { + noSuchRole: { + message: 'No such role.', + code: 'NO_SUCH_ROLE', + id: 'de0d6ecd-8e0a-4253-88ff-74bc89ae3d45', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roleId: { type: 'string', format: 'misskey:id' }, + }, + required: [ + 'roleId', + ], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps) => { + const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); + if (role == null) { + throw new ApiError(meta.errors.noSuchRole); + } + await this.rolesRepository.delete({ + id: ps.roleId, + }); + this.globalEventService.publishInternalEvent('roleDeleted', role); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/roles/list.ts b/packages/backend/src/server/api/endpoints/admin/roles/list.ts new file mode 100644 index 0000000000..458a8d535b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/roles/list.ts @@ -0,0 +1,39 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RolesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; + +export const meta = { + tags: ['admin', 'role'], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + }, + required: [ + ], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + private roleEntityService: RoleEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const roles = await this.rolesRepository.find({ + order: { lastUsedAt: 'DESC' }, + }); + return await this.roleEntityService.packMany(roles, me, { detail: false }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/roles/show.ts b/packages/backend/src/server/api/endpoints/admin/roles/show.ts new file mode 100644 index 0000000000..c83f96191d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/roles/show.ts @@ -0,0 +1,50 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RolesRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; + +export const meta = { + tags: ['admin', 'role'], + + requireCredential: true, + requireModerator: true, + + errors: { + noSuchRole: { + message: 'No such role.', + code: 'NO_SUCH_ROLE', + id: '07dc7d34-c0d8-49b7-96c6-db3ce64ee0b3', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roleId: { type: 'string', format: 'misskey:id' }, + }, + required: [ + 'roleId', + ], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + private roleEntityService: RoleEntityService, + ) { + super(meta, paramDef, async (ps) => { + const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); + if (role == null) { + throw new ApiError(meta.errors.noSuchRole); + } + return await this.roleEntityService.pack(role); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts b/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts new file mode 100644 index 0000000000..141cc5ee89 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/roles/unassign.ts @@ -0,0 +1,101 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RoleAssignmentsRepository, RolesRepository, UsersRepository } from '@/models/index.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { RoleService } from '@/core/RoleService.js'; + +export const meta = { + tags: ['admin', 'role'], + + requireCredential: true, + requireModerator: true, + + errors: { + noSuchRole: { + message: 'No such role.', + code: 'NO_SUCH_ROLE', + id: '6e519036-a70d-4c76-b679-bc8fb18194e2', + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '2b730f78-1179-461b-88ad-d24c9af1a5ce', + }, + + notAssigned: { + message: 'Not assigned.', + code: 'NOT_ASSIGNED', + id: 'b9060ac7-5c94-4da4-9f55-2047c953df44', + }, + + accessDenied: { + message: 'Only administrators can edit members of the role.', + code: 'ACCESS_DENIED', + id: '24636eee-e8c1-493e-94b2-e16ad401e262', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roleId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id' }, + }, + required: [ + 'roleId', + 'userId', + ], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + @Inject(DI.roleAssignmentsRepository) + private roleAssignmentsRepository: RoleAssignmentsRepository, + + private globalEventService: GlobalEventService, + private roleService: RoleService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); + if (role == null) { + throw new ApiError(meta.errors.noSuchRole); + } + + if (!role.canEditMembersByModerator && !(await this.roleService.isAdministrator(me))) { + throw new ApiError(meta.errors.accessDenied); + } + + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const roleAssignment = await this.roleAssignmentsRepository.findOneBy({ userId: user.id, roleId: role.id }); + if (roleAssignment == null) { + throw new ApiError(meta.errors.notAssigned); + } + + await this.roleAssignmentsRepository.delete(roleAssignment.id); + + this.rolesRepository.update(ps.roleId, { + lastUsedAt: new Date(), + }); + + this.globalEventService.publishInternalEvent('userRoleUnassigned', roleAssignment); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update-default-role-override.ts b/packages/backend/src/server/api/endpoints/admin/roles/update-default-role-override.ts new file mode 100644 index 0000000000..35da04efd2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/roles/update-default-role-override.ts @@ -0,0 +1,42 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RolesRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { MetaService } from '@/core/MetaService.js'; + +export const meta = { + tags: ['admin', 'role'], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + options: { + type: 'object', + }, + }, + required: [ + 'options', + ], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + private metaService: MetaService, + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps) => { + await this.metaService.update({ + defaultRoleOverride: ps.options, + }); + this.globalEventService.publishInternalEvent('defaultRoleOverrideUpdated', ps.options); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update.ts b/packages/backend/src/server/api/endpoints/admin/roles/update.ts new file mode 100644 index 0000000000..7d97d68e14 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/roles/update.ts @@ -0,0 +1,82 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RolesRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['admin', 'role'], + + requireCredential: true, + requireAdmin: true, + + errors: { + noSuchRole: { + message: 'No such role.', + code: 'NO_SUCH_ROLE', + id: 'cd23ef55-09ad-428a-ac61-95a45e124b32', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roleId: { type: 'string', format: 'misskey:id' }, + name: { type: 'string' }, + description: { type: 'string' }, + color: { type: 'string', nullable: true }, + isPublic: { type: 'boolean' }, + isModerator: { type: 'boolean' }, + isAdministrator: { type: 'boolean' }, + canEditMembersByModerator: { type: 'boolean' }, + options: { + type: 'object', + }, + }, + required: [ + 'roleId', + 'name', + 'description', + 'color', + 'isPublic', + 'isModerator', + 'isAdministrator', + 'canEditMembersByModerator', + 'options', + ], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps) => { + const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); + if (role == null) { + throw new ApiError(meta.errors.noSuchRole); + } + + const date = new Date(); + await this.rolesRepository.update(ps.roleId, { + updatedAt: date, + name: ps.name, + description: ps.description, + color: ps.color, + isPublic: ps.isPublic, + isModerator: ps.isModerator, + isAdministrator: ps.isAdministrator, + canEditMembersByModerator: ps.canEditMembersByModerator, + options: ps.options, + }); + const updated = await this.rolesRepository.findOneByOrFail({ id: ps.roleId }); + this.globalEventService.publishInternalEvent('roleUpdated', updated); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index e4031cf960..3f4ec299af 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -2,6 +2,8 @@ import { Inject, Injectable } from '@nestjs/common'; import type { UsersRepository, SigninsRepository, UserProfilesRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; +import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; export const meta = { tags: ['admin'], @@ -35,6 +37,9 @@ export default class extends Endpoint { @Inject(DI.signinsRepository) private signinsRepository: SigninsRepository, + + private roleService: RoleService, + private roleEntityService: RoleEntityService, ) { super(meta, paramDef, async (ps, me) => { const [user, profile] = await Promise.all([ @@ -46,15 +51,16 @@ export default class extends Endpoint { throw new Error('user not found'); } + const isModerator = await this.roleService.isModerator(user); + const isSilenced = !(await this.roleService.getUserRoleOptions(user.id)).canPublicNote; + const _me = await this.usersRepository.findOneByOrFail({ id: me.id }); - if ((_me.isModerator && !_me.isAdmin) && user.isAdmin) { + if (!await this.roleService.isAdministrator(_me) && await this.roleService.isAdministrator(user)) { throw new Error('cannot show info of admin'); } - if (!_me.isAdmin) { + if (!await this.roleService.isAdministrator(_me)) { return { - isModerator: user.isModerator, - isSilenced: user.isSilenced, isSuspended: user.isSuspended, }; } @@ -66,6 +72,8 @@ export default class extends Endpoint { const signins = await this.signinsRepository.findBy({ userId: user.id }); + const roles = await this.roleService.getUserRoles(user.id); + return { email: profile.email, emailVerified: profile.emailVerified, @@ -80,12 +88,13 @@ export default class extends Endpoint { mutedWords: profile.mutedWords, mutedInstances: profile.mutedInstances, mutingNotificationTypes: profile.mutingNotificationTypes, - isModerator: user.isModerator, - isSilenced: user.isSilenced, + isModerator: isModerator, + isSilenced: isSilenced, isSuspended: user.isSuspended, lastActiveDate: user.lastActiveDate, moderationNote: profile.moderationNote, signins, + roles: await this.roleEntityService.packMany(roles, me, { detail: false }), }; }); } diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts index 5a67cf522a..426973f282 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-users.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts @@ -4,6 +4,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { sqlLikeEscape } from '@/misc/sql-like-escape.js'; +import { RoleService } from '@/core/RoleService.js'; export const meta = { tags: ['admin'], @@ -28,7 +29,7 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, offset: { type: 'integer', default: 0 }, sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt', '+lastActiveDate', '-lastActiveDate'] }, - state: { type: 'string', enum: ['all', 'alive', 'available', 'admin', 'moderator', 'adminOrModerator', 'silenced', 'suspended'], default: 'all' }, + state: { type: 'string', enum: ['all', 'alive', 'available', 'admin', 'moderator', 'adminOrModerator', 'suspended'], default: 'all' }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' }, username: { type: 'string', nullable: true, default: null }, hostname: { @@ -49,18 +50,33 @@ export default class extends Endpoint { private usersRepository: UsersRepository, private userEntityService: UserEntityService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const query = this.usersRepository.createQueryBuilder('user'); switch (ps.state) { case 'available': query.where('user.isSuspended = FALSE'); break; - case 'admin': query.where('user.isAdmin = TRUE'); break; - case 'moderator': query.where('user.isModerator = TRUE'); break; - case 'adminOrModerator': query.where('user.isAdmin = TRUE OR user.isModerator = TRUE'); break; case 'alive': query.where('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; - case 'silenced': query.where('user.isSilenced = TRUE'); break; case 'suspended': query.where('user.isSuspended = TRUE'); break; + case 'admin': { + const adminIds = await this.roleService.getAdministratorIds(); + if (adminIds.length === 0) return []; + query.where('user.id IN (:...adminIds)', { adminIds: adminIds }); + break; + } + case 'moderator': { + const moderatorIds = await this.roleService.getModeratorIds(false); + if (moderatorIds.length === 0) return []; + query.where('user.id IN (:...moderatorIds)', { moderatorIds: moderatorIds }); + break; + } + case 'adminOrModerator': { + const adminOrModeratorIds = await this.roleService.getModeratorIds(); + if (adminOrModeratorIds.length === 0) return []; + query.where('user.id IN (:...adminOrModeratorIds)', { adminOrModeratorIds: adminOrModeratorIds }); + break; + } } switch (ps.origin) { diff --git a/packages/backend/src/server/api/endpoints/admin/silence-user.ts b/packages/backend/src/server/api/endpoints/admin/silence-user.ts deleted file mode 100644 index b9dbd211e0..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/silence-user.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import type { UsersRepository } from '@/models/index.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: true, - requireModerator: true, -} as const; - -export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], -} as const; - -// eslint-disable-next-line import/no-default-export -@Injectable() -export default class extends Endpoint { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private moderationLogService: ModerationLogService, - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } - - if (user.isAdmin) { - throw new Error('cannot silence admin'); - } - - await this.usersRepository.update(user.id, { - isSilenced: true, - }); - - this.globalEventService.publishInternalEvent('userChangeSilencedState', { id: user.id, isSilenced: true }); - - this.moderationLogService.insertModerationLog(me, 'silence', { - targetId: user.id, - }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts index 9fc1391570..3ad6c7c484 100644 --- a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts @@ -9,6 +9,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js'; import { DI } from '@/di-symbols.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; export const meta = { tags: ['admin'], @@ -41,6 +42,7 @@ export default class extends Endpoint { private userEntityService: UserEntityService, private userFollowingService: UserFollowingService, private userSuspendService: UserSuspendService, + private roleService: RoleService, private moderationLogService: ModerationLogService, private globalEventService: GlobalEventService, ) { @@ -51,12 +53,8 @@ export default class extends Endpoint { throw new Error('user not found'); } - if (user.isAdmin) { - throw new Error('cannot suspend admin'); - } - - if (user.isModerator) { - throw new Error('cannot suspend moderator'); + if (await this.roleService.isModerator(user)) { + throw new Error('cannot suspend moderator account'); } await this.usersRepository.update(user.id, { diff --git a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts deleted file mode 100644 index 3a9d410de0..0000000000 --- a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { Endpoint } from '@/server/api/endpoint-base.js'; -import type { UsersRepository } from '@/models/index.js'; -import { GlobalEventService } from '@/core/GlobalEventService.js'; -import { ModerationLogService } from '@/core/ModerationLogService.js'; -import { DI } from '@/di-symbols.js'; - -export const meta = { - tags: ['admin'], - - requireCredential: true, - requireModerator: true, -} as const; - -export const paramDef = { - type: 'object', - properties: { - userId: { type: 'string', format: 'misskey:id' }, - }, - required: ['userId'], -} as const; - -// eslint-disable-next-line import/no-default-export -@Injectable() -export default class extends Endpoint { - constructor( - @Inject(DI.usersRepository) - private usersRepository: UsersRepository, - - private moderationLogService: ModerationLogService, - private globalEventService: GlobalEventService, - ) { - super(meta, paramDef, async (ps, me) => { - const user = await this.usersRepository.findOneBy({ id: ps.userId }); - - if (user == null) { - throw new Error('user not found'); - } - - await this.usersRepository.update(user.id, { - isSilenced: false, - }); - - this.globalEventService.publishInternalEvent('userChangeSilencedState', { id: user.id, isSilenced: false }); - - this.moderationLogService.insertModerationLog(me, 'unsilence', { - targetId: user.id, - }); - }); - } -} diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 795b8460f3..c766494e6b 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -19,8 +19,6 @@ export const paramDef = { type: 'object', properties: { disableRegistration: { type: 'boolean', nullable: true }, - disableLocalTimeline: { type: 'boolean', nullable: true }, - disableGlobalTimeline: { type: 'boolean', nullable: true }, useStarForReactionFallback: { type: 'boolean', nullable: true }, pinnedUsers: { type: 'array', nullable: true, items: { type: 'string', @@ -42,7 +40,6 @@ export const paramDef = { description: { type: 'string', nullable: true }, defaultLightTheme: { type: 'string', nullable: true }, defaultDarkTheme: { type: 'string', nullable: true }, - localDriveCapacityMb: { type: 'integer' }, remoteDriveCapacityMb: { type: 'integer' }, cacheRemoteFiles: { type: 'boolean' }, emailRequiredForSignup: { type: 'boolean' }, @@ -130,14 +127,6 @@ export default class extends Endpoint { set.disableRegistration = ps.disableRegistration; } - if (typeof ps.disableLocalTimeline === 'boolean') { - set.disableLocalTimeline = ps.disableLocalTimeline; - } - - if (typeof ps.disableGlobalTimeline === 'boolean') { - set.disableGlobalTimeline = ps.disableGlobalTimeline; - } - if (typeof ps.useStarForReactionFallback === 'boolean') { set.useStarForReactionFallback = ps.useStarForReactionFallback; } @@ -194,10 +183,6 @@ export default class extends Endpoint { set.defaultDarkTheme = ps.defaultDarkTheme; } - if (ps.localDriveCapacityMb !== undefined) { - set.localDriveCapacityMb = ps.localDriveCapacityMb; - } - if (ps.remoteDriveCapacityMb !== undefined) { set.remoteDriveCapacityMb = ps.remoteDriveCapacityMb; } diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts index 2378660ec8..08625250c8 100644 --- a/packages/backend/src/server/api/endpoints/antennas/create.ts +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -5,6 +5,7 @@ import type { UserListsRepository, UserGroupJoiningsRepository, AntennasReposito import { GlobalEventService } from '@/core/GlobalEventService.js'; import { AntennaEntityService } from '@/core/entities/AntennaEntityService.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -83,6 +84,7 @@ export default class extends Endpoint { private userGroupJoiningsRepository: UserGroupJoiningsRepository, private antennaEntityService: AntennaEntityService, + private roleService: RoleService, private idService: IdService, private globalEventService: GlobalEventService, ) { @@ -90,7 +92,7 @@ export default class extends Endpoint { const currentAntennasCount = await this.antennasRepository.countBy({ userId: me.id, }); - if (currentAntennasCount > 5) { + if (currentAntennasCount > (await this.roleService.getUserRoleOptions(me.id)).antennaLimit) { throw new ApiError(meta.errors.tooManyAntennas); } diff --git a/packages/backend/src/server/api/endpoints/drive.ts b/packages/backend/src/server/api/endpoints/drive.ts index 6f40225f15..2a06792dcf 100644 --- a/packages/backend/src/server/api/endpoints/drive.ts +++ b/packages/backend/src/server/api/endpoints/drive.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { MetaService } from '@/core/MetaService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; +import { RoleService } from '@/core/RoleService.js'; export const meta = { tags: ['drive', 'account'], @@ -38,6 +39,7 @@ export default class extends Endpoint { constructor( private metaService: MetaService, private driveFileEntityService: DriveFileEntityService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { const instance = await this.metaService.fetch(true); @@ -45,8 +47,10 @@ export default class extends Endpoint { // Calculate drive usage const usage = await this.driveFileEntityService.calcDriveUsageOf(me.id); + const myRole = await this.roleService.getUserRoleOptions(me.id); + return { - capacity: 1024 * 1024 * (me.driveCapacityOverrideMb ?? instance.localDriveCapacityMb), + capacity: 1024 * 1024 * myRole.driveCapacityMb, usage: usage, }; }); diff --git a/packages/backend/src/server/api/endpoints/drive/files/delete.ts b/packages/backend/src/server/api/endpoints/drive/files/delete.ts index be7b050907..2ced97ee02 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/delete.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/delete.ts @@ -4,6 +4,7 @@ import type { DriveFilesRepository } from '@/models/index.js'; import { DriveService } from '@/core/DriveService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -46,6 +47,7 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private driveService: DriveService, + private roleService: RoleService, private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { @@ -55,7 +57,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchFile); } - if ((!me.isAdmin && !me.isModerator) && (file.userId !== me.id)) { + if (!await this.roleService.isModerator(me) && (file.userId !== me.id)) { throw new ApiError(meta.errors.accessDenied); } diff --git a/packages/backend/src/server/api/endpoints/drive/files/show.ts b/packages/backend/src/server/api/endpoints/drive/files/show.ts index 474d599cb6..e0a07a3640 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/show.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/show.ts @@ -4,6 +4,7 @@ import type { DriveFilesRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -62,6 +63,7 @@ export default class extends Endpoint { private driveFilesRepository: DriveFilesRepository, private driveFileEntityService: DriveFileEntityService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { let file: DriveFile | null = null; @@ -84,7 +86,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchFile); } - if ((!me.isAdmin && !me.isModerator) && (file.userId !== me.id)) { + if (!await this.roleService.isModerator(me) && (file.userId !== me.id)) { throw new ApiError(meta.errors.accessDenied); } diff --git a/packages/backend/src/server/api/endpoints/drive/files/update.ts b/packages/backend/src/server/api/endpoints/drive/files/update.ts index 9e2c767277..0fe57de6a8 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/update.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/update.ts @@ -5,6 +5,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../../error.js'; export const meta = { @@ -72,6 +73,7 @@ export default class extends Endpoint { private driveFoldersRepository: DriveFoldersRepository, private driveFileEntityService: DriveFileEntityService, + private roleService: RoleService, private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { @@ -81,7 +83,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.noSuchFile); } - if ((!me.isAdmin && !me.isModerator) && (file.userId !== me.id)) { + if (!await this.roleService.isModerator(me) && (file.userId !== me.id)) { throw new ApiError(meta.errors.accessDenied); } diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts index c44d63d64b..f87fca63e3 100644 --- a/packages/backend/src/server/api/endpoints/meta.ts +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -7,6 +7,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { MetaService } from '@/core/MetaService.js'; import type { Config } from '@/config.js'; import { DI } from '@/di-symbols.js'; +import { DEFAULT_ROLE } from '@/core/RoleService.js'; export const meta = { tags: ['meta'], @@ -77,18 +78,6 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, - disableLocalTimeline: { - type: 'boolean', - optional: false, nullable: false, - }, - disableGlobalTimeline: { - type: 'boolean', - optional: false, nullable: false, - }, - driveCapacityPerLocalUserMb: { - type: 'number', - optional: false, nullable: false, - }, driveCapacityPerRemoteUserMb: { type: 'number', optional: false, nullable: false, @@ -314,9 +303,6 @@ export default class extends Endpoint { repositoryUrl: instance.repositoryUrl, feedbackUrl: instance.feedbackUrl, disableRegistration: instance.disableRegistration, - disableLocalTimeline: instance.disableLocalTimeline, - disableGlobalTimeline: instance.disableGlobalTimeline, - driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, emailRequiredForSignup: instance.emailRequiredForSignup, enableHcaptcha: instance.enableHcaptcha, @@ -353,6 +339,8 @@ export default class extends Endpoint { translatorAvailable: instance.deeplAuthKey != null, + baseRole: { ...DEFAULT_ROLE, ...instance.defaultRoleOverride }, + ...(ps.detail ? { pinnedPages: instance.pinnedPages, pinnedClipId: instance.pinnedClipId, @@ -369,8 +357,6 @@ export default class extends Endpoint { response.proxyAccountName = proxyAccount ? proxyAccount.username : null; response.features = { registration: !instance.disableRegistration, - localTimeLine: !instance.disableLocalTimeline, - globalTimeLine: !instance.disableGlobalTimeline, emailRequiredForSignup: instance.emailRequiredForSignup, elasticsearch: this.config.elasticsearch ? true : false, hcaptcha: instance.enableHcaptcha, diff --git a/packages/backend/src/server/api/endpoints/notes/delete.ts b/packages/backend/src/server/api/endpoints/notes/delete.ts index 3c6e7bf768..16c4c01387 100644 --- a/packages/backend/src/server/api/endpoints/notes/delete.ts +++ b/packages/backend/src/server/api/endpoints/notes/delete.ts @@ -4,8 +4,9 @@ import type { UsersRepository } from '@/models/index.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteDeleteService } from '@/core/NoteDeleteService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['notes'], @@ -51,6 +52,7 @@ export default class extends Endpoint { private usersRepository: UsersRepository, private getterService: GetterService, + private roleService: RoleService, private noteDeleteService: NoteDeleteService, ) { super(meta, paramDef, async (ps, me) => { @@ -59,7 +61,7 @@ export default class extends Endpoint { throw err; }); - if ((!me.isAdmin && !me.isModerator) && (note.userId !== me.id)) { + if (!await this.roleService.isModerator(me) && (note.userId !== me.id)) { throw new ApiError(meta.errors.accessDenied); } diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index b6eaccb5ac..081563493d 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -6,6 +6,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { MetaService } from '@/core/MetaService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -57,14 +58,13 @@ export default class extends Endpoint { private noteEntityService: NoteEntityService, private queryService: QueryService, private metaService: MetaService, + private roleService: RoleService, private activeUsersChart: ActiveUsersChart, ) { super(meta, paramDef, async (ps, me) => { - const m = await this.metaService.fetch(); - if (m.disableGlobalTimeline) { - if (me == null || (!me.isAdmin && !me.isModerator)) { - throw new ApiError(meta.errors.gtlDisabled); - } + const role = await this.roleService.getUserRoleOptions(me ? me.id : null); + if (!role.gtlAvailable) { + throw new ApiError(meta.errors.gtlDisabled); } //#region Construct query diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 58bbf223a1..b2c504448e 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -7,6 +7,7 @@ import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -66,11 +67,12 @@ export default class extends Endpoint { private noteEntityService: NoteEntityService, private queryService: QueryService, private metaService: MetaService, + private roleService: RoleService, private activeUsersChart: ActiveUsersChart, ) { super(meta, paramDef, async (ps, me) => { - const m = await this.metaService.fetch(); - if (m.disableLocalTimeline && (!me.isAdmin && !me.isModerator)) { + const role = await this.roleService.getUserRoleOptions(me.id); + if (!role.ltlAvailable) { throw new ApiError(meta.errors.stlDisabled); } diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index d3594814b0..6361edc310 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -7,6 +7,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { MetaService } from '@/core/MetaService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { DI } from '@/di-symbols.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -62,14 +63,13 @@ export default class extends Endpoint { private noteEntityService: NoteEntityService, private queryService: QueryService, private metaService: MetaService, + private roleService: RoleService, private activeUsersChart: ActiveUsersChart, ) { super(meta, paramDef, async (ps, me) => { - const m = await this.metaService.fetch(); - if (m.disableLocalTimeline) { - if (me == null || (!me.isAdmin && !me.isModerator)) { - throw new ApiError(meta.errors.ltlDisabled); - } + const role = await this.roleService.getUserRoleOptions(me ? me.id : null); + if (!role.ltlAvailable) { + throw new ApiError(meta.errors.ltlDisabled); } //#region Construct query diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts index b015129a7a..8becb68a34 100644 --- a/packages/backend/src/server/api/endpoints/users.ts +++ b/packages/backend/src/server/api/endpoints/users.ts @@ -27,7 +27,7 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, offset: { type: 'integer', default: 0 }, sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] }, - state: { type: 'string', enum: ['all', 'admin', 'moderator', 'adminOrModerator', 'alive'], default: 'all' }, + state: { type: 'string', enum: ['all', 'alive'], default: 'all' }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, hostname: { type: 'string', @@ -54,9 +54,6 @@ export default class extends Endpoint { query.where('user.isExplorable = TRUE'); switch (ps.state) { - case 'admin': query.andWhere('user.isAdmin = TRUE'); break; - case 'moderator': query.andWhere('user.isModerator = TRUE'); break; - case 'adminOrModerator': query.andWhere('user.isAdmin = TRUE OR user.isModerator = TRUE'); break; case 'alive': query.andWhere('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break; } diff --git a/packages/backend/src/server/api/endpoints/users/report-abuse.ts b/packages/backend/src/server/api/endpoints/users/report-abuse.ts index 13badab727..d19d4007d6 100644 --- a/packages/backend/src/server/api/endpoints/users/report-abuse.ts +++ b/packages/backend/src/server/api/endpoints/users/report-abuse.ts @@ -7,8 +7,9 @@ import { GlobalEventService } from '@/core/GlobalEventService.js'; import { MetaService } from '@/core/MetaService.js'; import { EmailService } from '@/core/EmailService.js'; import { DI } from '@/di-symbols.js'; -import { ApiError } from '../../error.js'; import { GetterService } from '@/server/api/GetterService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { ApiError } from '../../error.js'; export const meta = { tags: ['users'], @@ -61,6 +62,7 @@ export default class extends Endpoint { private metaService: MetaService, private emailService: EmailService, private getterService: GetterService, + private roleService: RoleService, private globalEventService: GlobalEventService, ) { super(meta, paramDef, async (ps, me) => { @@ -74,7 +76,7 @@ export default class extends Endpoint { throw new ApiError(meta.errors.cannotReportYourself); } - if (user.isAdmin) { + if (await this.roleService.isAdministrator(user)) { throw new ApiError(meta.errors.cannotReportAdmin); } @@ -90,13 +92,7 @@ export default class extends Endpoint { // Publish event to moderators setImmediate(async () => { - const moderators = await this.usersRepository.find({ - where: [{ - isAdmin: true, - }, { - isModerator: true, - }], - }); + const moderators = await this.roleService.getModerators(); for (const moderator of moderators) { this.globalEventService.publishAdminStream(moderator.id, 'newAbuseUserReport', { diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts index fcdaeae1c9..70258ef009 100644 --- a/packages/backend/src/server/api/endpoints/users/show.ts +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -7,6 +7,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js'; import { DI } from '@/di-symbols.js'; import PerUserPvChart from '@/core/chart/charts/per-user-pv.js'; +import { RoleService } from '@/core/RoleService.js'; import { ApiError } from '../../error.js'; import { ApiLoggerService } from '../../ApiLoggerService.js'; import type { FindOptionsWhere } from 'typeorm'; @@ -91,20 +92,21 @@ export default class extends Endpoint { private userEntityService: UserEntityService, private remoteUserResolveService: RemoteUserResolveService, + private roleService: RoleService, private perUserPvChart: PerUserPvChart, private apiLoggerService: ApiLoggerService, ) { super(meta, paramDef, async (ps, me, _1, _2, _3, ip) => { let user; - const isAdminOrModerator = me && (me.isAdmin || me.isModerator); + const isModerator = await this.roleService.isModerator(me); if (ps.userIds) { if (ps.userIds.length === 0) { return []; } - const users = await this.usersRepository.findBy(isAdminOrModerator ? { + const users = await this.usersRepository.findBy(isModerator ? { id: In(ps.userIds), } : { id: In(ps.userIds), @@ -135,7 +137,7 @@ export default class extends Endpoint { user = await this.usersRepository.findOneBy(q); } - if (user == null || (!isAdminOrModerator && user.isSuspended)) { + if (user == null || (!isModerator && user.isSuspended)) { throw new ApiError(meta.errors.noSuchUser); } diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index 34f782e580..185c813869 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -7,6 +7,7 @@ import type { Packed } from '@/misc/schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; import Channel from '../channel.js'; class GlobalTimelineChannel extends Channel { @@ -16,6 +17,7 @@ class GlobalTimelineChannel extends Channel { constructor( private metaService: MetaService, + private roleService: RoleService, private noteEntityService: NoteEntityService, id: string, @@ -27,10 +29,8 @@ class GlobalTimelineChannel extends Channel { @bindThis public async init(params: any) { - const meta = await this.metaService.fetch(); - if (meta.disableGlobalTimeline) { - if (this.user == null || (!this.user.isAdmin && !this.user.isModerator)) return; - } + const role = await this.roleService.getUserRoleOptions(this.user ? this.user.id : null); + if (!role.gtlAvailable) return; // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -95,6 +95,7 @@ export class GlobalTimelineChannelService { constructor( private metaService: MetaService, + private roleService: RoleService, private noteEntityService: NoteEntityService, ) { } @@ -103,6 +104,7 @@ export class GlobalTimelineChannelService { public create(id: string, connection: Channel['connection']): GlobalTimelineChannel { return new GlobalTimelineChannel( this.metaService, + this.roleService, this.noteEntityService, id, connection, diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 6c6afb12bf..a0f75f202c 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -8,6 +8,7 @@ import { DI } from '@/di-symbols.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; import Channel from '../channel.js'; class HybridTimelineChannel extends Channel { @@ -17,6 +18,7 @@ class HybridTimelineChannel extends Channel { constructor( private metaService: MetaService, + private roleService: RoleService, private noteEntityService: NoteEntityService, id: string, @@ -28,8 +30,8 @@ class HybridTimelineChannel extends Channel { @bindThis public async init(params: any): Promise { - const meta = await this.metaService.fetch(); - if (meta.disableLocalTimeline && !this.user!.isAdmin && !this.user!.isModerator) return; + const role = await this.roleService.getUserRoleOptions(this.user ? this.user.id : null); + if (!role.ltlAvailable) return; // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -112,6 +114,7 @@ export class HybridTimelineChannelService { constructor( private metaService: MetaService, + private roleService: RoleService, private noteEntityService: NoteEntityService, ) { } @@ -120,6 +123,7 @@ export class HybridTimelineChannelService { public create(id: string, connection: Channel['connection']): HybridTimelineChannel { return new HybridTimelineChannel( this.metaService, + this.roleService, this.noteEntityService, id, connection, diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 54388787ef..7d76f42fe7 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -6,6 +6,7 @@ import type { Packed } from '@/misc/schema.js'; import { MetaService } from '@/core/MetaService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; +import { RoleService } from '@/core/RoleService.js'; import Channel from '../channel.js'; class LocalTimelineChannel extends Channel { @@ -15,6 +16,7 @@ class LocalTimelineChannel extends Channel { constructor( private metaService: MetaService, + private roleService: RoleService, private noteEntityService: NoteEntityService, id: string, @@ -26,10 +28,8 @@ class LocalTimelineChannel extends Channel { @bindThis public async init(params: any) { - const meta = await this.metaService.fetch(); - if (meta.disableLocalTimeline) { - if (this.user == null || (!this.user.isAdmin && !this.user.isModerator)) return; - } + const role = await this.roleService.getUserRoleOptions(this.user ? this.user.id : null); + if (!role.ltlAvailable) return; // Subscribe events this.subscriber.on('notesStream', this.onNote); @@ -92,6 +92,7 @@ export class LocalTimelineChannelService { constructor( private metaService: MetaService, + private roleService: RoleService, private noteEntityService: NoteEntityService, ) { } @@ -100,6 +101,7 @@ export class LocalTimelineChannelService { public create(id: string, connection: Channel['connection']): LocalTimelineChannel { return new LocalTimelineChannel( this.metaService, + this.roleService, this.noteEntityService, id, connection, diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index ec05be56ee..3bc844f949 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -14,23 +14,33 @@ import type { Page } from '@/models/entities/Page.js'; import type { Packed } from '@/misc/schema.js'; import type { Webhook } from '@/models/entities/Webhook.js'; import type { Meta } from '@/models/entities/Meta.js'; +import { Role, RoleAssignment } from '@/models'; import type Emitter from 'strict-event-emitter-types'; import type { EventEmitter } from 'events'; +// redis通すとDateのインスタンスはstringに変換されるので +type Serialized = { + [K in keyof T]: T[K] extends Date ? string : T[K]; +}; + //#region Stream type-body definitions export interface InternalStreamTypes { - userChangeSuspendedState: { id: User['id']; isSuspended: User['isSuspended']; }; - userChangeSilencedState: { id: User['id']; isSilenced: User['isSilenced']; }; - userChangeModeratorState: { id: User['id']; isModerator: User['isModerator']; }; - userTokenRegenerated: { id: User['id']; oldToken: User['token']; newToken: User['token']; }; - remoteUserUpdated: { id: User['id']; }; - webhookCreated: Webhook; - webhookDeleted: Webhook; - webhookUpdated: Webhook; - antennaCreated: Antenna; - antennaDeleted: Antenna; - antennaUpdated: Antenna; - metaUpdated: Meta, + userChangeSuspendedState: Serialized<{ id: User['id']; isSuspended: User['isSuspended']; }>; + userTokenRegenerated: Serialized<{ id: User['id']; oldToken: User['token']; newToken: User['token']; }>; + remoteUserUpdated: Serialized<{ id: User['id']; }>; + defaultRoleOverrideUpdated: Serialized; + roleCreated: Serialized; + roleDeleted: Serialized; + roleUpdated: Serialized; + userRoleAssigned: Serialized; + userRoleUnassigned: Serialized; + webhookCreated: Serialized; + webhookDeleted: Serialized; + webhookUpdated: Serialized; + antennaCreated: Serialized; + antennaDeleted: Serialized; + antennaUpdated: Serialized; + metaUpdated: Serialized; } export interface BroadcastTypes { diff --git a/packages/backend/src/server/web/ClientServerService.ts b/packages/backend/src/server/web/ClientServerService.ts index 5c29224019..2a764a25b0 100644 --- a/packages/backend/src/server/web/ClientServerService.ts +++ b/packages/backend/src/server/web/ClientServerService.ts @@ -29,6 +29,7 @@ import type { ChannelsRepository, ClipsRepository, EmojisRepository, FlashsRepos import { deepClone } from '@/misc/clone.js'; import { bindThis } from '@/decorators.js'; import { FlashEntityService } from '@/core/entities/FlashEntityService.js'; +import { RoleService } from '@/core/RoleService.js'; import manifest from './manifest.json' assert { type: 'json' }; import { FeedService } from './FeedService.js'; import { UrlPreviewService } from './UrlPreviewService.js'; @@ -83,6 +84,7 @@ export class ClientServerService { private metaService: MetaService, private urlPreviewService: UrlPreviewService, private feedService: FeedService, + private roleService: RoleService, @Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, @@ -125,7 +127,12 @@ export class ClientServerService { throw new Error('login required'); } const user = await this.usersRepository.findOneBy({ token }); - if (user == null || !(user.isAdmin || user.isModerator)) { + if (user == null) { + reply.code(403); + throw new Error('no such user'); + } + const isAdministrator = await this.roleService.isAdministrator(user); + if (!isAdministrator) { reply.code(403); throw new Error('access denied'); } diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 86df3308ec..e2fc27fecd 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -301,6 +301,10 @@ const meta = await res.json(); + if (meta.version == null) { + throw new Error('failed to fetch instance metadata'); + } + if (meta.version != v) { localStorage.setItem('v', meta.version); refresh(); diff --git a/packages/frontend/src/components/MkRolePreview.vue b/packages/frontend/src/components/MkRolePreview.vue new file mode 100644 index 0000000000..ddd7dbb250 --- /dev/null +++ b/packages/frontend/src/components/MkRolePreview.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue index 1a4c494987..be8a4c408e 100644 --- a/packages/frontend/src/components/MkUserCardMini.vue +++ b/packages/frontend/src/components/MkUserCardMini.vue @@ -1,5 +1,5 @@ + - - - - - - - - - + + +
@@ -180,12 +181,16 @@ import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; import { iAmAdmin, iAmModerator } from '@/account'; import { instance } from '@/instance'; +import MkRolePreview from '@/components/MkRolePreview.vue'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ userId: string; -}>(); + initialTab?: string; +}>(), { + initialTab: 'overview', +}); -let tab = $ref('overview'); +let tab = $ref(props.initialTab); let chartSrc = $ref('per-user-notes'); let user = $ref(); let init = $ref>(); @@ -195,7 +200,6 @@ let ap = $ref(null); let moderator = $ref(false); let silenced = $ref(false); let suspended = $ref(false); -let driveCapacityOverrideMb: number | null = $ref(0); let moderationNote = $ref(''); const filesPagination = { endpoint: 'admin/drive/files' as const, @@ -220,7 +224,6 @@ function createFetcher() { moderator = info.isModerator; silenced = info.isSilenced; suspended = info.isSuspended; - driveCapacityOverrideMb = user.driveCapacityOverrideMb; moderationNote = info.moderationNote; watch($$(moderationNote), async () => { @@ -257,19 +260,6 @@ async function resetPassword() { }); } -async function toggleSilence(v) { - const confirm = await os.confirm({ - type: 'warning', - text: v ? i18n.ts.silenceConfirm : i18n.ts.unsilenceConfirm, - }); - if (confirm.canceled) { - silenced = !v; - } else { - await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: user.id }); - await refreshUser(); - } -} - async function toggleSuspend(v) { const confirm = await os.confirm({ type: 'warning', @@ -283,11 +273,6 @@ async function toggleSuspend(v) { } } -async function toggleModerator(v) { - await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: user.id }); - await refreshUser(); -} - async function deleteAllFiles() { const confirm = await os.confirm({ type: 'warning', @@ -307,22 +292,6 @@ async function deleteAllFiles() { await refreshUser(); } -async function applyDriveCapacityOverride() { - let driveCapOrMb = driveCapacityOverrideMb; - if (driveCapacityOverrideMb && driveCapacityOverrideMb < 0) { - driveCapOrMb = null; - } - try { - await os.apiWithDialog('admin/drive-capacity-override', { userId: user.id, overrideMb: driveCapOrMb }); - await refreshUser(); - } catch (err) { - os.alert({ - type: 'error', - text: err.toString(), - }); - } -} - async function deleteAccount() { const confirm = await os.confirm({ type: 'warning', @@ -347,6 +316,31 @@ async function deleteAccount() { } } +async function assignRole() { + const roles = await os.api('admin/roles/list'); + + const { canceled, result: roleId } = await os.select({ + title: i18n.ts._role.chooseRoleToAssign, + items: roles.map(r => ({ text: r.name, value: r.id })), + }); + if (canceled) return; + + await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.id }); + refreshUser(); +} + +async function unassignRole(role, ev) { + os.popupMenu([{ + text: i18n.ts.unassign, + icon: 'ti ti-x', + danger: true, + action: async () => { + await os.apiWithDialog('admin/roles/unassign', { roleId: role.id, userId: user.id }); + refreshUser(); + }, + }], ev.currentTarget ?? ev.target); +} + watch(() => props.userId, () => { init = createFetcher(); }, { @@ -484,4 +478,19 @@ definePageMetadata(computed(() => ({ margin-left: auto; } } + +.roleItem { + display: flex; +} + +.role { + flex: 1; +} + +.roleUnassign { + width: 32px; + height: 32px; + margin-left: 8px; + align-self: center; +} diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 07d34a794d..eea4d20094 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -18,7 +18,6 @@
-
@@ -35,7 +34,6 @@
-
@@ -189,7 +187,7 @@ onMounted(() => { const bd = parseInt(props.user.birthday.split('-')[2]); if (m === bm && d === bd) { confetti({ - duration: 1000 * 4 + duration: 1000 * 4, }); } } diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index 4b9f49f8fd..05dcd7806e 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -37,6 +37,7 @@ export const routes = [{ }, { path: '/user-info/:userId', component: page(() => import('./pages/user-info.vue')), + hash: 'initialTab', }, { path: '/instance-info/:host', component: page(() => import('./pages/instance-info.vue')), @@ -351,6 +352,22 @@ export const routes = [{ path: '/ads', name: 'ads', component: page(() => import('./pages/admin/ads.vue')), + }, { + path: '/roles/:id/edit', + name: 'roles', + component: page(() => import('./pages/admin/roles.edit.vue')), + }, { + path: '/roles/new', + name: 'roles', + component: page(() => import('./pages/admin/roles.edit.vue')), + }, { + path: '/roles/:id', + name: 'roles', + component: page(() => import('./pages/admin/roles.role.vue')), + }, { + path: '/roles', + name: 'roles', + component: page(() => import('./pages/admin/roles.vue')), }, { path: '/database', name: 'database', diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 7ede64c327..74bd61fd78 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -108,26 +108,6 @@ export function getUserMenu(user, router: Router = mainRouter) { }); } - async function toggleSilence() { - if (!await getConfirmed(i18n.t(user.isSilenced ? 'unsilenceConfirm' : 'silenceConfirm'))) return; - - os.apiWithDialog(user.isSilenced ? 'admin/unsilence-user' : 'admin/silence-user', { - userId: user.id, - }).then(() => { - user.isSilenced = !user.isSilenced; - }); - } - - async function toggleSuspend() { - if (!await getConfirmed(i18n.t(user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return; - - os.apiWithDialog(user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', { - userId: user.id, - }).then(() => { - user.isSuspended = !user.isSuspended; - }); - } - function reportAbuse() { os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { user: user, @@ -218,13 +198,11 @@ export function getUserMenu(user, router: Router = mainRouter) { if (iAmModerator) { menu = menu.concat([null, { - icon: 'ti ti-microphone-2-off', - text: user.isSilenced ? i18n.ts.unsilence : i18n.ts.silence, - action: toggleSilence, - }, { - icon: 'ti ti-snowflake', - text: user.isSuspended ? i18n.ts.unsuspend : i18n.ts.suspend, - action: toggleSuspend, + icon: 'ti ti-user-exclamation', + text: i18n.ts.moderation, + action: () => { + router.push('/user-info/' + user.id + '#moderation'); + }, }]); } } diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index f75e526939..b8a0a504a3 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -45,9 +45,7 @@ onMounted(() => { if (props.column.tl == null) { setType(); } else if ($i) { - disabled = !$i.isModerator && !$i.isAdmin && ( - instance.disableLocalTimeline && ['local', 'social'].includes(props.column.tl) || - instance.disableGlobalTimeline && ['global'].includes(props.column.tl)); + disabled = false; // TODO } }); -- cgit v1.2.3-freya From d2204fd5c8fb2361e9c29ed31cd0d40eb69d2f4b Mon Sep 17 00:00:00 2001 From: tamaina Date: Fri, 13 Jan 2023 18:25:40 +0900 Subject: refactor: pagination/date-separated-list系処理を良い感じに? (#8209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * pages/messaging/messaging-room.vue * wip * wip * wip??? * wip? * :v: * messaaging-room.form.vue rewrite to compositon api * refactor * 関心事でないのでとりあえず置いておく * :art: * :art: * i18n.ts * fix scroll container find function * fix * FIX * :v: * Fix scroll bottom detect * wip * aaaaaaaaaaa * rename * fix * fix? * :v: * :v: * clean up * clena up * refactor * scroll event once or not * fix * fix once * add safe-area-inset-bottom to spacer * fix * :v: * :art: * fix * fix * wip * :v: * clean up * fix lint * Update packages/client/src/components/global/sticky-container.vue Co-authored-by: Johann150 * Update packages/client/src/components/ui/pagination.vue Co-authored-by: Johann150 * Update packages/client/src/pages/messaging/messaging-room.form.vue Co-authored-by: Johann150 * clean up: single line comment * https://github.com/misskey-dev/misskey/pull/8209#discussion_r867386077 * fix * asobi → tolerance * pick form * pick message * pick room * fix lint * fix scroll? * fix scroll.ts * fix directives/sticky-container * update global/sticky-container.vue * fix, :art: * revert merge * :v: * fix lint errors * :art: * Update packages/client/src/types/date-separated-list.ts Co-authored-by: Acid Chicken (硫酸鶏) * https://github.com/misskey-dev/misskey/pull/8209#discussion_r917225080 * use ' * Update packages/client/src/scripts/scroll.ts Co-authored-by: Acid Chicken (硫酸鶏) * use Number.EPSILON Co-authored-by: acid-chicken * revert * fix * fix * Use % instead of vh * :art: * :art: * :art: * wip * wip * css modules Co-authored-by: Johann150 Co-authored-by: Acid Chicken (硫酸鶏) --- .../src/components/MkDateSeparatedList.vue | 192 ++++++++------ packages/frontend/src/components/MkNotes.vue | 11 +- packages/frontend/src/components/MkPagination.vue | 286 +++++++++++++-------- packages/frontend/src/pages/messaging/index.vue | 2 +- .../src/pages/messaging/messaging-room.form.vue | 3 +- .../src/pages/messaging/messaging-room.vue | 91 +++---- packages/frontend/src/scripts/scroll.ts | 120 ++++++--- packages/frontend/src/types/date-separated-list.ts | 6 + packages/frontend/src/ui/visitor/a.vue | 4 +- 9 files changed, 447 insertions(+), 268 deletions(-) create mode 100644 packages/frontend/src/types/date-separated-list.ts (limited to 'packages/frontend/src/scripts') diff --git a/packages/frontend/src/components/MkDateSeparatedList.vue b/packages/frontend/src/components/MkDateSeparatedList.vue index 5d8e14c3c8..cb88444d34 100644 --- a/packages/frontend/src/components/MkDateSeparatedList.vue +++ b/packages/frontend/src/components/MkDateSeparatedList.vue @@ -1,13 +1,14 @@ - + diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index 73659fd501..f9952e4245 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -9,7 +9,16 @@ - +