diff options
| author | Marie <Marie@kaifa.ch> | 2023-12-23 02:09:23 +0100 |
|---|---|---|
| committer | Marie <Marie@kaifa.ch> | 2023-12-23 02:09:23 +0100 |
| commit | 5db583a3eb61d50de14d875ebf7ecef20490e313 (patch) | |
| tree | 783dd43d2ac660c32e745a4485d499e9ddc43324 /packages/frontend/src/components | |
| parent | add: Custom MOTDs (diff) | |
| parent | Update CHANGELOG.md (diff) | |
| download | sharkey-5db583a3eb61d50de14d875ebf7ecef20490e313.tar.gz sharkey-5db583a3eb61d50de14d875ebf7ecef20490e313.tar.bz2 sharkey-5db583a3eb61d50de14d875ebf7ecef20490e313.zip | |
merge: upstream
Diffstat (limited to 'packages/frontend/src/components')
131 files changed, 2328 insertions, 1814 deletions
diff --git a/packages/frontend/src/components/MkAbuseReport.vue b/packages/frontend/src/components/MkAbuseReport.vue index 14247f4bf5..611c8a1782 100644 --- a/packages/frontend/src/components/MkAbuseReport.vue +++ b/packages/frontend/src/components/MkAbuseReport.vue @@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <Mfm :text="report.comment"/> </div> <hr/> - <div>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></div> + <div>{{ i18n.ts.reporter }}: <MkA :to="`/admin/user/${report.reporter.id}`" class="_link">@{{ report.reporter.username }}</MkA></div> <div v-if="report.assignee"> {{ i18n.ts.moderator }}: <MkAcct :user="report.assignee"/> @@ -41,6 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import { ref } from 'vue'; import MkButton from '@/components/MkButton.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; @@ -56,11 +57,11 @@ const emit = defineEmits<{ (ev: 'resolved', reportId: string): void; }>(); -let forward = $ref(props.report.forwarded); +const forward = ref(props.report.forwarded); function resolve() { os.apiWithDialog('admin/resolve-abuse-user-report', { - forward: forward, + forward: forward.value, reportId: props.report.id, }).then(() => { emit('resolved', props.report.id); diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue index aac7f508a1..40f9ad4057 100644 --- a/packages/frontend/src/components/MkAchievements.vue +++ b/packages/frontend/src/components/MkAchievements.vue @@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { onMounted } from 'vue'; +import { onMounted, ref, computed } from 'vue'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/scripts/achievements.js'; @@ -67,15 +67,15 @@ const props = withDefaults(defineProps<{ withDescription: true, }); -let achievements = $ref(); -const lockedAchievements = $computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements ?? []).some(a => a.name === x))); +const achievements = ref(); +const lockedAchievements = computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements.value ?? []).some(a => a.name === x))); function fetch() { os.api('users/achievements', { userId: props.user.id }).then(res => { - achievements = []; + achievements.value = []; for (const t of ACHIEVEMENT_TYPES) { const a = res.find(x => x.name === t); - if (a) achievements.push(a); + if (a) achievements.value.push(a); } //achievements = res.sort((a, b) => b.unlockedAt - a.unlockedAt); }); diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue index cd2c4d8264..0e252f7b1d 100644 --- a/packages/frontend/src/components/MkAnalogClock.vue +++ b/packages/frontend/src/components/MkAnalogClock.vue @@ -138,45 +138,45 @@ const texts = computed(() => { }); let enabled = true; -let majorGraduationColor = $ref<string>(); +const majorGraduationColor = ref<string>(); //let minorGraduationColor = $ref<string>(); -let sHandColor = $ref<string>(); -let mHandColor = $ref<string>(); -let hHandColor = $ref<string>(); -let nowColor = $ref<string>(); -let h = $ref<number>(0); -let m = $ref<number>(0); -let s = $ref<number>(0); -let hAngle = $ref<number>(0); -let mAngle = $ref<number>(0); -let sAngle = $ref<number>(0); -let disableSAnimate = $ref(false); +const sHandColor = ref<string>(); +const mHandColor = ref<string>(); +const hHandColor = ref<string>(); +const nowColor = ref<string>(); +const h = ref<number>(0); +const m = ref<number>(0); +const s = ref<number>(0); +const hAngle = ref<number>(0); +const mAngle = ref<number>(0); +const sAngle = ref<number>(0); +const disableSAnimate = ref(false); let sOneRound = false; const sLine = ref<SVGPathElement>(); function tick() { const now = props.now(); now.setMinutes(now.getMinutes() + now.getTimezoneOffset() + props.offset); - const previousS = s; - const previousM = m; - const previousH = h; - s = now.getSeconds(); - m = now.getMinutes(); - h = now.getHours(); - if (previousS === s && previousM === m && previousH === h) { + const previousS = s.value; + const previousM = m.value; + const previousH = h.value; + s.value = now.getSeconds(); + m.value = now.getMinutes(); + h.value = now.getHours(); + if (previousS === s.value && previousM === m.value && previousH === h.value) { return; } - hAngle = Math.PI * (h % (props.twentyfour ? 24 : 12) + (m + s / 60) / 60) / (props.twentyfour ? 12 : 6); - mAngle = Math.PI * (m + s / 60) / 30; + hAngle.value = Math.PI * (h.value % (props.twentyfour ? 24 : 12) + (m.value + s.value / 60) / 60) / (props.twentyfour ? 12 : 6); + mAngle.value = Math.PI * (m.value + s.value / 60) / 30; if (sOneRound && sLine.value) { // 秒針が一周した際のアニメーションをよしなに処理する(これが無いと秒が59->0になったときに期待したアニメーションにならない) - sAngle = Math.PI * 60 / 30; + sAngle.value = Math.PI * 60 / 30; defaultIdlingRenderScheduler.delete(tick); sLine.value.addEventListener('transitionend', () => { - disableSAnimate = true; + disableSAnimate.value = true; requestAnimationFrame(() => { - sAngle = 0; + sAngle.value = 0; requestAnimationFrame(() => { - disableSAnimate = false; + disableSAnimate.value = false; if (enabled) { defaultIdlingRenderScheduler.add(tick); } @@ -184,9 +184,9 @@ function tick() { }); }, { once: true }); } else { - sAngle = Math.PI * s / 30; + sAngle.value = Math.PI * s.value / 30; } - sOneRound = s === 59; + sOneRound = s.value === 59; } tick(); @@ -195,12 +195,12 @@ function calcColors() { const computedStyle = getComputedStyle(document.documentElement); const dark = tinycolor(computedStyle.getPropertyValue('--bg')).isDark(); const accent = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(); - majorGraduationColor = dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'; + majorGraduationColor.value = dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'; //minorGraduationColor = dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; - sHandColor = dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)'; - mHandColor = tinycolor(computedStyle.getPropertyValue('--fg')).toHexString(); - hHandColor = accent; - nowColor = accent; + sHandColor.value = dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)'; + mHandColor.value = tinycolor(computedStyle.getPropertyValue('--fg')).toHexString(); + hHandColor.value = accent; + nowColor.value = accent; } calcColors(); diff --git a/packages/frontend/src/components/MkAnimBg.vue b/packages/frontend/src/components/MkAnimBg.vue index 70d101a9d3..284ee8f3f8 100644 --- a/packages/frontend/src/components/MkAnimBg.vue +++ b/packages/frontend/src/components/MkAnimBg.vue @@ -21,8 +21,9 @@ const props = withDefaults(defineProps<{ focus: 1.0, }); -function loadShader(gl, type, source) { +function loadShader(gl: WebGLRenderingContext, type: number, source: string) { const shader = gl.createShader(type); + if (shader == null) return null; gl.shaderSource(shader, source); gl.compileShader(shader); @@ -38,11 +39,13 @@ function loadShader(gl, type, source) { return shader; } -function initShaderProgram(gl, vsSource, fsSource) { +function initShaderProgram(gl: WebGLRenderingContext, vsSource: string, fsSource: string) { const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource); const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource); const shaderProgram = gl.createProgram(); + if (shaderProgram == null || vertexShader == null || fragmentShader == null) return null; + gl.attachShader(shaderProgram, vertexShader); gl.attachShader(shaderProgram, fragmentShader); gl.linkProgram(shaderProgram); @@ -63,8 +66,10 @@ let handle: ReturnType<typeof window['requestAnimationFrame']> | null = null; onMounted(() => { const canvas = canvasEl.value!; - canvas.width = canvas.offsetWidth; - canvas.height = canvas.offsetHeight; + let width = canvas.offsetWidth; + let height = canvas.offsetHeight; + canvas.width = width; + canvas.height = height; const gl = canvas.getContext('webgl', { premultipliedAlpha: true }); if (gl == null) return; @@ -197,6 +202,7 @@ onMounted(() => { gl_FragColor = vec4( color, max(max(color.x, color.y), color.z) ); } `); + if (shaderProgram == null) return; gl.useProgram(shaderProgram); const u_resolution = gl.getUniformLocation(shaderProgram, 'u_resolution'); @@ -226,7 +232,23 @@ onMounted(() => { gl!.uniform1f(u_time, 0); gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4); } else { - function render(timeStamp) { + function render(timeStamp: number) { + let sizeChanged = false; + if (Math.abs(height - canvas.offsetHeight) > 2) { + height = canvas.offsetHeight; + canvas.height = height; + sizeChanged = true; + } + if (Math.abs(width - canvas.offsetWidth) > 2) { + width = canvas.offsetWidth; + canvas.width = width; + sizeChanged = true; + } + if (sizeChanged && gl) { + gl.uniform2fv(u_resolution, [width, height]); + gl.viewport(0, 0, width, height); + } + gl!.uniform1f(u_time, timeStamp); gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4); diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index 9596ce6077..60978eb0bd 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -43,6 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only fixed :instant="true" :initialText="c.form.text" + :initialCw="c.form.cw" /> </div> <MkFolder v-else-if="c.type === 'folder'" :defaultOpen="c.opened"> @@ -60,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { Ref } from 'vue'; +import { Ref, ref } from 'vue'; import * as os from '@/os.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -87,16 +88,17 @@ function g(id) { return props.components.find(x => x.value.id === id).value; } -let valueForSwitch = $ref(c.default ?? false); +const valueForSwitch = ref(c.default ?? false); function onSwitchUpdate(v) { - valueForSwitch = v; + valueForSwitch.value = v; if (c.onChange) c.onChange(v); } function openPostForm() { os.post({ initialText: c.form.text, + initialCw: c.form.cw, instant: true, }); } diff --git a/packages/frontend/src/components/MkAutocomplete.vue b/packages/frontend/src/components/MkAutocomplete.vue index 9e92c4bb03..1f819cf601 100644 --- a/packages/frontend/src/components/MkAutocomplete.vue +++ b/packages/frontend/src/components/MkAutocomplete.vue @@ -242,29 +242,7 @@ function exec() { return; } - const matched: EmojiDef[] = []; - const max = 30; - - emojiDb.value.some(x => { - if (x.name.toLowerCase().startsWith(props.q ? props.q.toLowerCase() : '') && !x.aliasOf && !matched.some(y => y.emoji.toLowerCase() === x.emoji.toLowerCase())) matched.push(x); - return matched.length === max; - }); - - if (matched.length < max) { - emojiDb.value.some(x => { - if (x.name.toLowerCase().startsWith(props.q ? props.q.toLowerCase() : '') && !matched.some(y => y.emoji.toLowerCase() === x.emoji.toLowerCase())) matched.push(x); - return matched.length === max; - }); - } - - if (matched.length < max) { - emojiDb.value.some(x => { - if (x.name.toLowerCase().includes(props.q ? props.q.toLowerCase() : '') && !matched.some(y => y.emoji.toLowerCase() === x.emoji.toLowerCase())) matched.push(x); - return matched.length === max; - }); - } - - emojis.value = matched; + emojis.value = emojiAutoComplete(props.q.toLowerCase(), emojiDb.value); } else if (props.type === 'mfmTag') { if (!props.q || props.q === '') { mfmTags.value = MFM_TAGS; @@ -275,6 +253,78 @@ function exec() { } } +type EmojiScore = { emoji: EmojiDef, score: number }; + +function emojiAutoComplete(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] { + if (!query) { + return []; + } + + const matched = new Map<string, EmojiScore>(); + + // 前方一致(エイリアスなし) + emojiDb.some(x => { + if (x.name.toLowerCase().startsWith(query) && !x.aliasOf) { + matched.set(x.name, { emoji: x, score: query.length + 1 }); + } + return matched.size === max; + }); + + // 前方一致(エイリアス込み) + if (matched.size < max) { + emojiDb.some(x => { + if (x.name.toLowerCase().startsWith(query) && !matched.has(x.aliasOf ?? x.name)) { + matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length }); + } + return matched.size === max; + }); + } + + // 部分一致(エイリアス込み) + if (matched.size < max) { + emojiDb.some(x => { + if (x.name.toLowerCase().includes(query) && !matched.has(x.aliasOf ?? x.name)) { + matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length - 1 }); + } + return matched.size === max; + }); + } + + // 簡易あいまい検索(3文字以上) + if (matched.size < max && query.length > 3) { + const queryChars = [...query]; + const hitEmojis = new Map<string, EmojiScore>(); + + for (const x of emojiDb) { + // 文字列の位置を進めながら、クエリの文字を順番に探す + + let pos = 0; + let hit = 0; + for (const c of queryChars) { + pos = x.name.toLowerCase().indexOf(c, pos); + if (pos <= -1) break; + hit++; + } + + // 半分以上の文字が含まれていればヒットとする + if (hit > Math.ceil(queryChars.length / 2) && hit - 2 > (matched.get(x.aliasOf ?? x.name)?.score ?? 0)) { + hitEmojis.set(x.aliasOf ?? x.name, { emoji: x, score: hit - 2 }); + } + } + + // ヒットしたものを全部追加すると雑多になるので、先頭の6件程度だけにしておく(6件=オートコンプリートのポップアップのサイズ分) + [...hitEmojis.values()] + .sort((x, y) => y.score - x.score) + .slice(0, 6) + .forEach(it => matched.set(it.emoji.name, it)); + } + + return [...matched.values()] + .sort((x, y) => y.score - x.score) + .slice(0, max) + .map(it => it.emoji); +} + function onMousedown(event: Event) { if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close(); } @@ -309,12 +359,25 @@ function onKeydown(event: KeyboardEvent) { } break; - case 'Tab': case 'ArrowDown': cancel(); selectNext(); break; + case 'Tab': + if (event.shiftKey) { + if (select.value !== -1) { + cancel(); + selectPrev(); + } else { + props.close(); + } + } else { + cancel(); + selectNext(); + } + break; + default: event.stopPropagation(); props.textarea.focus(); diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue index 2fdc2bbe07..9fcc49d3f0 100644 --- a/packages/frontend/src/components/MkButton.vue +++ b/packages/frontend/src/components/MkButton.vue @@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted } from 'vue'; +import { nextTick, onMounted, shallowRef } from 'vue'; const props = defineProps<{ type?: 'button' | 'submit' | 'reset'; @@ -59,13 +59,13 @@ const emit = defineEmits<{ (ev: 'click', payload: MouseEvent): void; }>(); -let el = $shallowRef<HTMLElement | null>(null); -let ripples = $shallowRef<HTMLElement | null>(null); +const el = shallowRef<HTMLElement | null>(null); +const ripples = shallowRef<HTMLElement | null>(null); onMounted(() => { if (props.autofocus) { nextTick(() => { - el!.focus(); + el.value!.focus(); }); } }); @@ -88,11 +88,11 @@ function onMousedown(evt: MouseEvent): void { const rect = target.getBoundingClientRect(); const ripple = document.createElement('div'); - ripple.classList.add(ripples!.dataset.childrenClass!); + ripple.classList.add(ripples.value!.dataset.childrenClass!); ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px'; ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px'; - ripples!.appendChild(ripple); + ripples.value!.appendChild(ripple); const circleCenterX = evt.clientX - rect.left; const circleCenterY = evt.clientY - rect.top; @@ -107,7 +107,7 @@ function onMousedown(evt: MouseEvent): void { ripple.style.opacity = '0'; }, 1000); window.setTimeout(() => { - if (ripples) ripples.removeChild(ripple); + if (ripples.value) ripples.value.removeChild(ripple); }, 2000); } </script> diff --git a/packages/frontend/src/components/MkChannelPreview.vue b/packages/frontend/src/components/MkChannelPreview.vue index 9c6e2f00bd..96590a469b 100644 --- a/packages/frontend/src/components/MkChannelPreview.vue +++ b/packages/frontend/src/components/MkChannelPreview.vue @@ -4,49 +4,70 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1"> - <div class="banner" :style="bannerStyle"> - <div class="fade"></div> - <div class="name"><i class="ph-television ph-bold ph-lg"></i> {{ channel.name }}</div> - <div v-if="channel.isSensitive" class="sensitiveIndicator">{{ i18n.ts.sensitive }}</div> - <div class="status"> - <div> - <i class="ph-users ph-bold ph-lg"></i> - <I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"> - <template #n> - <b>{{ channel.usersCount }}</b> - </template> - </I18n> - </div> - <div> - <i class="ph-pencil ph-bold ph-lg"></i> - <I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"> - <template #n> - <b>{{ channel.notesCount }}</b> - </template> - </I18n> +<div style="position: relative;"> + <MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1" @click="updateLastReadedAt"> + <div class="banner" :style="bannerStyle"> + <div class="fade"></div> + <div class="name"><i class="ph-television ph-bold ph-lg"></i> {{ channel.name }}</div> + <div v-if="channel.isSensitive" class="sensitiveIndicator">{{ i18n.ts.sensitive }}</div> + <div class="status"> + <div> + <i class="ph-users ph-bold ph-lg"></i> + <I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"> + <template #n> + <b>{{ channel.usersCount }}</b> + </template> + </I18n> + </div> + <div> + <i class="ph-pencil ph-bold ph-lg"></i> + <I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"> + <template #n> + <b>{{ channel.notesCount }}</b> + </template> + </I18n> + </div> </div> </div> - </div> - <article v-if="channel.description"> - <p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p> - </article> - <footer> - <span v-if="channel.lastNotedAt"> - {{ i18n.ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/> - </span> - </footer> -</MkA> + <article v-if="channel.description"> + <p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p> + </article> + <footer> + <span v-if="channel.lastNotedAt"> + {{ i18n.ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/> + </span> + </footer> + </MkA> + <div + v-if="channel.lastNotedAt && (channel.isFavorited || channel.isFollowing) && (!lastReadedAt || Date.parse(channel.lastNotedAt) > lastReadedAt)" + class="indicator" + ></div> +</div> </template> <script lang="ts" setup> -import { computed } from 'vue'; +import { computed, ref, watch } from 'vue'; import { i18n } from '@/i18n.js'; +import { miLocalStorage } from '@/local-storage.js'; const props = defineProps<{ channel: Record<string, any>; }>(); +const getLastReadedAt = (): number | null => { + return miLocalStorage.getItemAsJson(`channelLastReadedAt:${props.channel.id}`) ?? null; +}; + +const lastReadedAt = ref(getLastReadedAt()); + +watch(() => props.channel.id, () => { + lastReadedAt.value = getLastReadedAt(); +}); + +const updateLastReadedAt = () => { + lastReadedAt.value = props.channel.lastNotedAt ? Date.parse(props.channel.lastNotedAt) : Date.now(); +}; + const bannerStyle = computed(() => { if (props.channel.bannerUrl) { return { backgroundImage: `url(${props.channel.bannerUrl})` }; @@ -170,4 +191,17 @@ const bannerStyle = computed(() => { } } +.indicator { + position: absolute; + top: 0; + right: 0; + transform: translate(25%, -25%); + background-color: var(--accent); + border: solid var(--bg) 4px; + border-radius: 100%; + width: 1.5rem; + height: 1.5rem; + aspect-ratio: 1 / 1; +} + </style> diff --git a/packages/frontend/src/components/MkChart.vue b/packages/frontend/src/components/MkChart.vue index fe7077bdbf..adb3c134ae 100644 --- a/packages/frontend/src/components/MkChart.vue +++ b/packages/frontend/src/components/MkChart.vue @@ -74,7 +74,7 @@ const props = defineProps({ }, }); -let legendEl = $shallowRef<InstanceType<typeof MkChartLegend>>(); +const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>(); const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); const negate = arr => arr.map(x => -x); @@ -268,7 +268,7 @@ const render = () => { gradient, }, }, - plugins: [chartVLine(vLineColor), ...(props.detailed ? [chartLegend(legendEl)] : [])], + plugins: [chartVLine(vLineColor), ...(props.detailed ? [chartLegend(legendEl.value)] : [])], }); }; diff --git a/packages/frontend/src/components/MkChartLegend.vue b/packages/frontend/src/components/MkChartLegend.vue index 546bc0b4b1..c265fe6e97 100644 --- a/packages/frontend/src/components/MkChartLegend.vue +++ b/packages/frontend/src/components/MkChartLegend.vue @@ -13,29 +13,30 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import { shallowRef } from 'vue'; import { Chart, LegendItem } from 'chart.js'; const props = defineProps({ }); -let chart = $shallowRef<Chart>(); -let items = $shallowRef<LegendItem[]>([]); +const chart = shallowRef<Chart>(); +const items = shallowRef<LegendItem[]>([]); function update(_chart: Chart, _items: LegendItem[]) { - chart = _chart, - items = _items; + chart.value = _chart, + items.value = _items; } function onClick(item: LegendItem) { - if (chart == null) return; - const { type } = chart.config; + if (chart.value == null) return; + const { type } = chart.value.config; if (type === 'pie' || type === 'doughnut') { // Pie and doughnut charts only have a single dataset and visibility is per item - chart.toggleDataVisibility(item.index); + chart.value.toggleDataVisibility(item.index); } else { - chart.setDatasetVisibility(item.datasetIndex, !chart.isDatasetVisible(item.datasetIndex)); + chart.value.setDatasetVisibility(item.datasetIndex, !chart.value.isDatasetVisible(item.datasetIndex)); } - chart.update(); + chart.value.update(); } defineExpose({ diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue index 71914c6886..1e72319010 100644 --- a/packages/frontend/src/components/MkClickerGame.vue +++ b/packages/frontend/src/components/MkClickerGame.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, onUnmounted } from 'vue'; +import { computed, onMounted, onUnmounted, ref } from 'vue'; import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue'; import * as os from '@/os.js'; import { useInterval } from '@/scripts/use-interval.js'; @@ -29,8 +29,8 @@ import { claimAchievement } from '@/scripts/achievements.js'; const saveData = game.saveData; const cookies = computed(() => saveData.value?.cookies); -let cps = $ref(0); -let prevCookies = $ref(0); +const cps = ref(0); +const prevCookies = ref(0); function onClick(ev: MouseEvent) { const x = ev.clientX; @@ -48,9 +48,9 @@ function onClick(ev: MouseEvent) { } useInterval(() => { - const diff = saveData.value!.cookies - prevCookies; - cps = diff; - prevCookies = saveData.value!.cookies; + const diff = saveData.value!.cookies - prevCookies.value; + cps.value = diff; + prevCookies.value = saveData.value!.cookies; }, 1000, { immediate: false, afterMounted: true, @@ -63,7 +63,7 @@ useInterval(game.save, 1000 * 5, { onMounted(async () => { await game.load(); - prevCookies = saveData.value!.cookies; + prevCookies.value = saveData.value!.cookies; }); onUnmounted(() => { diff --git a/packages/frontend/src/components/MkCode.core.vue b/packages/frontend/src/components/MkCode.core.vue index 21684b462a..19418cd4da 100644 --- a/packages/frontend/src/components/MkCode.core.vue +++ b/packages/frontend/src/components/MkCode.core.vue @@ -54,7 +54,7 @@ watch(() => props.lang, (to) => { return new Promise((resolve) => { fetchLanguage(to).then(() => resolve); }); -}, { immediate: true, }); +}, { immediate: true }); </script> <style scoped lang="scss"> @@ -62,7 +62,7 @@ watch(() => props.lang, (to) => { padding: 1em; margin: .5em 0; overflow: auto; - border-radius: .3em; + border-radius: 8px; & pre, & code { diff --git a/packages/frontend/src/components/MkCode.vue b/packages/frontend/src/components/MkCode.vue index b39e6ff23c..2c016e4d7c 100644 --- a/packages/frontend/src/components/MkCode.vue +++ b/packages/frontend/src/components/MkCode.vue @@ -4,18 +4,27 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> - <Suspense> - <template #fallback> - <MkLoading v-if="!inline ?? true" /> - </template> - <code v-if="inline" :class="$style.codeInlineRoot">{{ code }}</code> - <XCode v-else :code="code" :lang="lang"/> - </Suspense> +<Suspense> + <template #fallback> + <MkLoading v-if="!inline ?? true"/> + </template> + <code v-if="inline" :class="$style.codeInlineRoot">{{ code }}</code> + <XCode v-else-if="show && lang" :code="code" :lang="lang"/> + <pre v-else-if="show" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre> + <button v-else :class="$style.codePlaceholderRoot" @click="show = true"> + <div :class="$style.codePlaceholderContainer"> + <div><i class="ti ti-code"></i> {{ i18n.ts.code }}</div> + <div>{{ i18n.ts.clickToShow }}</div> + </div> + </button> +</Suspense> </template> <script lang="ts" setup> -import { defineAsyncComponent } from 'vue'; +import { defineAsyncComponent, ref } from 'vue'; import MkLoading from '@/components/global/MkLoading.vue'; +import { defaultStore } from '@/store.js'; +import { i18n } from '@/i18n.js'; defineProps<{ code: string; @@ -23,6 +32,8 @@ defineProps<{ inline?: boolean; }>(); +const show = ref(!defaultStore.state.dataSaver.code); + const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue')); </script> @@ -36,4 +47,42 @@ const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue')) padding: .1em; border-radius: .3em; } + +.codeBlockFallbackRoot { + display: block; + overflow-wrap: anywhere; + color: #D4D4D4; + background: #1E1E1E; + padding: 1em; + margin: .5em 0; + overflow: auto; + border-radius: 8px; +} + +.codeBlockFallbackCode { + font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace; +} + +.codePlaceholderRoot { + display: block; + width: 100%; + background: none; + border: none; + outline: none; + font: inherit; + color: inherit; + cursor: pointer; + + box-sizing: border-box; + border-radius: 8px; + padding: 24px; + margin-top: 4px; + color: #D4D4D4; + background: #1E1E1E; +} + +.codePlaceholderContainer { + text-align: center; + font-size: 0.8em; +} </style> diff --git a/packages/frontend/src/components/MkCodeEditor.vue b/packages/frontend/src/components/MkCodeEditor.vue index 5434042684..c9bcc71196 100644 --- a/packages/frontend/src/components/MkCodeEditor.vue +++ b/packages/frontend/src/components/MkCodeEditor.vue @@ -4,30 +4,38 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[$style.codeEditorRoot, { [$style.disabled]: disabled, [$style.focused]: focused }]"> - <div :class="$style.codeEditorScroller"> - <textarea - ref="inputEl" - v-model="vModel" - :class="[$style.textarea]" - :disabled="disabled" - :required="required" - :readonly="readonly" - autocomplete="off" - wrap="off" - spellcheck="false" - @focus="focused = true" - @blur="focused = false" - @keydown="onKeydown($event)" - @input="onInput" - ></textarea> - <XCode :class="$style.codeEditorHighlighter" :codeEditor="true" :code="v" :lang="lang"/> +<div> + <div :class="$style.label" @click="focus"><slot name="label"></slot></div> + <div :class="[$style.codeEditorRoot, { [$style.focused]: focused }]"> + <div :class="$style.codeEditorScroller"> + <textarea + ref="inputEl" + v-model="vModel" + :class="[$style.textarea]" + :disabled="disabled" + :required="required" + :readonly="readonly" + autocomplete="off" + wrap="off" + spellcheck="false" + @focus="focused = true" + @blur="focused = false" + @keydown="onKeydown($event)" + @input="onInput" + ></textarea> + <XCode :class="$style.codeEditorHighlighter" :codeEditor="true" :code="v" :lang="lang"/> + </div> </div> + <div :class="$style.caption"><slot name="caption"></slot></div> + <MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> </div> </template> <script lang="ts" setup> import { ref, watch, toRefs, shallowRef, nextTick } from 'vue'; +import { debounce } from 'throttle-debounce'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n.js'; import XCode from '@/components/MkCode.core.vue'; const props = withDefaults(defineProps<{ @@ -36,6 +44,8 @@ const props = withDefaults(defineProps<{ required?: boolean; readonly?: boolean; disabled?: boolean; + debounce?: boolean; + manualSave?: boolean; }>(), { lang: 'js', }); @@ -54,6 +64,8 @@ const focused = ref(false); const changed = ref(false); const inputEl = shallowRef<HTMLTextAreaElement>(); +const focus = () => inputEl.value?.focus(); + const onInput = (ev) => { v.value = ev.target?.value ?? v.value; changed.value = true; @@ -100,16 +112,48 @@ const updated = () => { emit('update:modelValue', v.value); }; +const debouncedUpdated = debounce(1000, updated); + watch(modelValue, newValue => { v.value = newValue ?? ''; }); -watch(v, () => { - updated(); +watch(v, newValue => { + if (!props.manualSave) { + if (props.debounce) { + debouncedUpdated(); + } else { + updated(); + } + } }); </script> <style lang="scss" module> +.label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; + + &:empty { + display: none; + } +} + +.caption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } +} + +.save { + margin: 8px 0 0 0; +} + .codeEditorRoot { min-width: 100%; max-width: 100%; @@ -117,6 +161,7 @@ watch(v, () => { overflow-y: hidden; box-sizing: border-box; margin: 0; + border-radius: 6px; padding: 0; color: var(--fg); border: solid 1px var(--panel); @@ -139,6 +184,10 @@ watch(v, () => { height: 100%; } +.textarea, .codeEditorHighlighter { + margin: 0; +} + .textarea { position: absolute; top: 0; @@ -153,7 +202,10 @@ watch(v, () => { caret-color: rgb(225, 228, 232); background-color: transparent; border: 0; + border-radius: 6px; outline: 0; + min-width: calc(100% - 24px); + height: 100%; padding: 12px; line-height: 1.5em; font-size: 1em; diff --git a/packages/frontend/src/components/MkColorInput.vue b/packages/frontend/src/components/MkColorInput.vue index 79b1949640..4f15e88951 100644 --- a/packages/frontend/src/components/MkColorInput.vue +++ b/packages/frontend/src/components/MkColorInput.vue @@ -24,8 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue'; -import { i18n } from '@/i18n.js'; +import { ref, shallowRef, toRefs } from 'vue'; const props = defineProps<{ modelValue: string | null; diff --git a/packages/frontend/src/components/MkContextMenu.vue b/packages/frontend/src/components/MkContextMenu.vue index 6cca7fc353..b78252be89 100644 --- a/packages/frontend/src/components/MkContextMenu.vue +++ b/packages/frontend/src/components/MkContextMenu.vue @@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onBeforeUnmount } from 'vue'; +import { onMounted, onBeforeUnmount, shallowRef, ref } from 'vue'; import MkMenu from './MkMenu.vue'; import { MenuItem } from './types/menu.vue'; import contains from '@/scripts/contains.js'; @@ -34,9 +34,9 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -let rootEl = $shallowRef<HTMLDivElement>(); +const rootEl = shallowRef<HTMLDivElement>(); -let zIndex = $ref<number>(os.claimZIndex('high')); +const zIndex = ref<number>(os.claimZIndex('high')); const SCROLLBAR_THICKNESS = 16; @@ -44,8 +44,8 @@ onMounted(() => { let left = props.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 let top = props.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1 - const width = rootEl.offsetWidth; - const height = rootEl.offsetHeight; + const width = rootEl.value.offsetWidth; + const height = rootEl.value.offsetHeight; if (left + width - window.pageXOffset >= (window.innerWidth - SCROLLBAR_THICKNESS)) { left = (window.innerWidth - SCROLLBAR_THICKNESS) - width + window.pageXOffset; @@ -63,8 +63,8 @@ onMounted(() => { left = 0; } - rootEl.style.top = `${top}px`; - rootEl.style.left = `${left}px`; + rootEl.value.style.top = `${top}px`; + rootEl.value.style.left = `${left}px`; document.body.addEventListener('mousedown', onMousedown); }); @@ -74,7 +74,7 @@ onBeforeUnmount(() => { }); function onMousedown(evt: Event) { - if (!contains(rootEl, evt.target) && (rootEl !== evt.target)) emit('closed'); + if (!contains(rootEl.value, evt.target) && (rootEl.value !== evt.target)) emit('closed'); } </script> diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue index 81f3936600..0a1ddd3171 100644 --- a/packages/frontend/src/components/MkCropperDialog.vue +++ b/packages/frontend/src/components/MkCropperDialog.vue @@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted } from 'vue'; +import { onMounted, shallowRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import Cropper from 'cropperjs'; import tinycolor from 'tinycolor2'; @@ -56,10 +56,10 @@ const props = defineProps<{ }>(); const imgUrl = getProxiedImageUrl(props.file.url, undefined, true); -let dialogEl = $shallowRef<InstanceType<typeof MkModalWindow>>(); -let imgEl = $shallowRef<HTMLImageElement>(); +const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); +const imgEl = shallowRef<HTMLImageElement>(); let cropper: Cropper | null = null; -let loading = $ref(true); +const loading = ref(true); const ok = async () => { const promise = new Promise<Misskey.entities.DriveFile>(async (res) => { @@ -94,16 +94,16 @@ const ok = async () => { const f = await promise; emit('ok', f); - dialogEl!.close(); + dialogEl.value!.close(); }; const cancel = () => { emit('cancel'); - dialogEl!.close(); + dialogEl.value!.close(); }; const onImageLoad = () => { - loading = false; + loading.value = false; if (cropper) { cropper.getCropperImage()!.$center('contain'); @@ -112,7 +112,7 @@ const onImageLoad = () => { }; onMounted(() => { - cropper = new Cropper(imgEl!, { + cropper = new Cropper(imgEl.value!, { }); const computedStyle = getComputedStyle(document.documentElement); diff --git a/packages/frontend/src/components/MkCwButton.vue b/packages/frontend/src/components/MkCwButton.vue index 0cdaf7c9bd..4a6d2dfba2 100644 --- a/packages/frontend/src/components/MkCwButton.vue +++ b/packages/frontend/src/components/MkCwButton.vue @@ -16,7 +16,23 @@ import MkButton from '@/components/MkButton.vue'; const props = defineProps<{ modelValue: boolean; - note: Misskey.entities.Note; + text: string | null; + renote: Misskey.entities.Note | null; + files: Misskey.entities.DriveFile[]; + poll?: { + expiresAt: string | null; + multiple: boolean; + choices: { + isVoted: boolean; + text: string; + votes: number; + }[]; + } | { + choices: string[]; + multiple: boolean; + expiresAt: string | null; + expiredAfter: string | null; + }; }>(); const emit = defineEmits<{ @@ -25,9 +41,10 @@ const emit = defineEmits<{ const label = computed(() => { return concat([ - props.note.text ? [i18n.t('_cw.chars', { count: props.note.text.length })] : [], - props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length })] : [], - props.note.poll != null ? [i18n.ts.poll] : [], + props.text ? [i18n.t('_cw.chars', { count: props.text.length })] : [], + props.renote ? [i18n.ts.quote] : [], + props.files.length !== 0 ? [i18n.t('_cw.files', { count: props.files.length })] : [], + props.poll != null ? [i18n.ts.poll] : [], ] as string[][]).join(' / '); }); diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index e0692eb383..2c0f6a4d78 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -30,8 +30,8 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown"> <template v-if="input.type === 'password'" #prefix><i class="ph-lock ph-bold ph-lg"></i></template> <template #caption> - <span v-if="okButtonDisabled && disabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })"/> - <span v-else-if="okButtonDisabled && disabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })"/> + <span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.t('_dialog.charactersExceeded', { current: (inputValue as string).length, max: input.maxLength ?? 'NaN' })"/> + <span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.t('_dialog.charactersBelow', { current: (inputValue as string).length, min: input.minLength ?? 'NaN' })"/> </template> </MkInput> <MkSelect v-if="select" v-model="selectedValue" autofocus> @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </MkSelect> <div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons"> - <MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> + <MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> <MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton> </div> <div v-if="actions" :class="$style.buttons"> @@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'; +import { onBeforeUnmount, onMounted, ref, shallowRef, computed } from 'vue'; import MkModal from '@/components/MkModal.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -122,24 +122,21 @@ const modal = shallowRef<InstanceType<typeof MkModal>>(); const inputValue = ref<string | number | null>(props.input?.default ?? null); const selectedValue = ref(props.select?.default ?? null); -let disabledReason = $ref<null | 'charactersExceeded' | 'charactersBelow'>(null); -const okButtonDisabled = $computed<boolean>(() => { +const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'charactersBelow'>(() => { if (props.input) { if (props.input.minLength) { if ((inputValue.value || inputValue.value === '') && (inputValue.value as string).length < props.input.minLength) { - disabledReason = 'charactersBelow'; - return true; + return 'charactersBelow'; } } if (props.input.maxLength) { if (inputValue.value && (inputValue.value as string).length > props.input.maxLength) { - disabledReason = 'charactersExceeded'; - return true; + return 'charactersExceeded'; } } } - return false; + return null; }); function done(canceled: boolean, result?) { diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 9f79a44d4c..dcaaa72cf4 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -39,6 +39,7 @@ import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { claimAchievement } from '@/scripts/achievements.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import { MenuItem } from '@/types/menu.js'; const props = withDefaults(defineProps<{ folder: Misskey.entities.DriveFolder; @@ -250,7 +251,7 @@ function setAsUploadFolder() { } function onContextmenu(ev: MouseEvent) { - let menu; + let menu: MenuItem[]; menu = [{ text: i18n.ts.openInWindow, icon: 'ph-app-window ph-bold ph-lg', @@ -260,18 +261,18 @@ function onContextmenu(ev: MouseEvent) { }, { }, 'closed'); }, - }, null, { + }, { type: 'divider' }, { text: i18n.ts.rename, icon: 'ph-textbox ph-bold ph-lg', action: rename, - }, null, { + }, { type: 'divider' }, { text: i18n.ts.delete, icon: 'ph-trash ph-bold ph-lg', danger: true, action: deleteFolder, }]; if (defaultStore.state.devMode) { - menu = menu.concat([null, { + menu = menu.concat([{ type: 'divider' }, { icon: 'ph-identification-card ph-bold ph-lg', text: i18n.ts.copyFolderId, action: () => { diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 5281541927..00bb0e6e2b 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -616,7 +616,7 @@ function getMenu() { type: 'switch', text: i18n.ts.keepOriginalUploading, ref: keepOriginal, - }, null, { + }, { type: 'divider' }, { text: i18n.ts.addFile, type: 'label', }, { @@ -627,7 +627,7 @@ function getMenu() { text: i18n.ts.fromUrl, icon: 'ph-link ph-bold ph-lg', action: () => { urlUpload(); }, - }, null, { + }, { type: 'divider' }, { text: folder.value ? folder.value.name : i18n.ts.drive, type: 'label', }, folder.value ? { diff --git a/packages/frontend/src/components/MkEmojiPicker.section.vue b/packages/frontend/src/components/MkEmojiPicker.section.vue index 00e0a0e042..49c146b68d 100644 --- a/packages/frontend/src/components/MkEmojiPicker.section.vue +++ b/packages/frontend/src/components/MkEmojiPicker.section.vue @@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと --> -<section> +<!-- フォルダの中にはカスタム絵文字だけ(Unicode絵文字もこっち) --> +<section v-if="!hasChildSection" v-panel style="border-radius: 6px; border-bottom: 0.5px solid var(--divider);"> <header class="_acrylic" @click="shown = !shown"> - <i class="toggle ti-fw" :class="shown ? 'ph-caret-down ph-bold ph-lg' : 'ph-caret-up ph-bold ph-lg'"></i> <slot></slot> ({{ emojis.length }}) + <i class="toggle ti-fw" :class="shown ? 'ph-caret-down ph-bold ph-lg' : 'ph-caret-up ph-bold ph-lg'"></i> <slot></slot> (<i class="ph-bold ph-lg"></i>:{{ emojis.length }}) </header> <div v-if="shown" class="body"> <button @@ -23,15 +24,52 @@ SPDX-License-Identifier: AGPL-3.0-only </button> </div> </section> +<!-- フォルダの中にはカスタム絵文字やフォルダがある --> +<section v-else v-panel style="border-radius: 6px; border-bottom: 0.5px solid var(--divider);"> + <header class="_acrylic" @click="shown = !shown"> + <i class="toggle ti-fw" :class="shown ? 'ph-caret-down ph-bold ph-lg' : 'ph-caret-up ph-bold ph-lg'"></i> <slot></slot> (<i class="ph-folder ph-bold ph-lg"></i>:{{ customEmojiTree.length }} <i class="ti ti-icons ti-fw"></i>:{{ emojis.length }}) + </header> + <div v-if="shown" style="padding-left: 9px;"> + <MkEmojiPickerSection + v-for="child in customEmojiTree" + :key="`custom:${child.value}`" + :initialShown="initialShown" + :emojis="computed(() => customEmojis.filter(e => e.category === child.category).map(e => `:${e.name}:`))" + :hasChildSection="child.children.length !== 0" + :customEmojiTree="child.children" + @chosen="nestedChosen" + > + {{ child.value || i18n.ts.other }} + </MkEmojiPickerSection> + </div> + <div v-if="shown" class="body"> + <button + v-for="emoji in emojis" + :key="emoji" + :data-emoji="emoji" + class="_button item" + @pointerenter="computeButtonTitle" + @click="emit('chosen', emoji, $event)" + > + <MkCustomEmoji v-if="emoji[0] === ':'" class="emoji" :name="emoji" :normal="true"/> + <MkEmoji v-else class="emoji" :emoji="emoji" :normal="true"/> + </button> + </div> +</section> </template> <script lang="ts" setup> import { ref, computed, Ref } from 'vue'; -import { getEmojiName } from '@/scripts/emojilist.js'; +import { i18n } from '../i18n.js'; +import { CustomEmojiFolderTree, getEmojiName } from '@/scripts/emojilist.js'; +import { customEmojis } from '@/custom-emojis.js'; +import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue'; const props = defineProps<{ emojis: string[] | Ref<string[]>; initialShown?: boolean; + hasChildSection?: boolean; + customEmojiTree?: CustomEmojiFolderTree[]; }>(); const emit = defineEmits<{ @@ -49,4 +87,7 @@ function computeButtonTitle(ev: MouseEvent): void { elm.title = getEmojiName(emoji) ?? emoji; } +function nestedChosen(emoji: any, ev?: MouseEvent) { + emit('chosen', emoji, ev); +} </script> diff --git a/packages/frontend/src/components/MkEmojiPicker.vue b/packages/frontend/src/components/MkEmojiPicker.vue index 50ed8048bb..b7e329d7c2 100644 --- a/packages/frontend/src/components/MkEmojiPicker.vue +++ b/packages/frontend/src/components/MkEmojiPicker.vue @@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only </section> <div v-if="tab === 'index'" class="group index"> - <section v-if="showPinned"> + <section v-if="showPinned && pinned.length > 0"> <div class="body"> <button v-for="emoji in pinned" @@ -73,18 +73,20 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-once class="group"> <header class="_acrylic">{{ i18n.ts.customEmojis }}</header> <XSection - v-for="category in customEmojiCategories" - :key="`custom:${category}`" + v-for="child in customEmojiFolderRoot.children" + :key="`custom:${child.value}`" :initialShown="false" - :emojis="computed(() => customEmojis.filter(e => category === null ? (e.category === 'null' || !e.category) : e.category === category).filter(filterAvailable).map(e => `:${e.name}:`))" + :emojis="computed(() => customEmojis.filter(e => child.value === '' ? (e.category === 'null' || !e.category) : e.category === child.value).filter(filterAvailable).map(e => `:${e.name}:`))" + :hasChildSection="child.children.length !== 0" + :customEmojiTree="child.children" @chosen="chosen" > - {{ category || i18n.ts.other }} + {{ child.value || i18n.ts.other }} </XSection> </div> <div v-once class="group"> <header class="_acrylic">{{ i18n.ts.emoji }}</header> - <XSection v-for="category in categories" :key="category" :emojis="emojiCharByCategory.get(category) ?? []" @chosen="chosen">{{ category }}</XSection> + <XSection v-for="category in categories" :key="category" :emojis="emojiCharByCategory.get(category) ?? []" :hasChildSection="false" @chosen="chosen">{{ category }}</XSection> </div> </div> <div class="tabs"> @@ -100,7 +102,14 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, shallowRef, computed, watch, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; import XSection from '@/components/MkEmojiPicker.section.vue'; -import { emojilist, emojiCharByCategory, UnicodeEmojiDef, unicodeEmojiCategories as categories, getEmojiName } from '@/scripts/emojilist.js'; +import { + emojilist, + emojiCharByCategory, + UnicodeEmojiDef, + unicodeEmojiCategories as categories, + getEmojiName, + CustomEmojiFolderTree, +} from '@/scripts/emojilist.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import * as os from '@/os.js'; import { isTouchUsing } from '@/scripts/touch.js'; @@ -112,10 +121,11 @@ import { $i } from '@/account.js'; const props = withDefaults(defineProps<{ showPinned?: boolean; - asReactionPicker?: boolean; + pinnedEmojis?: string[]; maxHeight?: number; asDrawer?: boolean; asWindow?: boolean; + asReactionPicker?: boolean; // 今は使われてないが将来的に使いそう }>(), { showPinned: true, }); @@ -128,22 +138,50 @@ const searchEl = shallowRef<HTMLInputElement>(); const emojisEl = shallowRef<HTMLDivElement>(); const { - reactions: pinned, - reactionPickerSize, - reactionPickerWidth, - reactionPickerHeight, - disableShowingAnimatedImages, + emojiPickerScale, + emojiPickerWidth, + emojiPickerHeight, recentlyUsedEmojis, } = defaultStore.reactiveState; -const size = computed(() => props.asReactionPicker ? reactionPickerSize.value : 1); -const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3); -const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2); +const pinned = computed(() => props.pinnedEmojis); +const size = computed(() => emojiPickerScale.value); +const width = computed(() => emojiPickerWidth.value); +const height = computed(() => emojiPickerHeight.value); const q = ref<string>(''); -const searchResultCustom = ref<Misskey.entities.CustomEmoji[]>([]); +const searchResultCustom = ref<Misskey.entities.EmojiSimple[]>([]); const searchResultUnicode = ref<UnicodeEmojiDef[]>([]); const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index'); +const customEmojiFolderRoot: CustomEmojiFolderTree = { value: '', category: '', children: [] }; + +function parseAndMergeCategories(input: string, root: CustomEmojiFolderTree): CustomEmojiFolderTree { + const parts = input.split('/').map(p => p.trim()); + let currentNode: CustomEmojiFolderTree = root; + + for (const part of parts) { + let existingNode = currentNode.children.find((node) => node.value === part); + + if (!existingNode) { + const newNode: CustomEmojiFolderTree = { value: part, category: input, children: [] }; + currentNode.children.push(newNode); + existingNode = newNode; + } + + currentNode = existingNode; + } + + return currentNode; +} + +customEmojiCategories.value.forEach(ec => { + if (ec !== null) { + parseAndMergeCategories(ec, customEmojiFolderRoot); + } +}); + +parseAndMergeCategories('', customEmojiFolderRoot); + watch(q, () => { if (emojisEl.value) emojisEl.value.scrollTop = 0; @@ -158,7 +196,7 @@ watch(q, () => { const searchCustom = () => { const max = 100; const emojis = customEmojis.value; - const matches = new Set<Misskey.entities.CustomEmoji>(); + const matches = new Set<Misskey.entities.EmojiSimple>(); const exactMatch = emojis.find(emoji => emoji.name === newQ); if (exactMatch) matches.add(exactMatch); @@ -288,7 +326,7 @@ watch(q, () => { searchResultUnicode.value = Array.from(searchUnicode()); }); -function filterAvailable(emoji: Misskey.entities.CustomEmoji): boolean { +function filterAvailable(emoji: Misskey.entities.EmojiSimple): boolean { return (emoji.roleIdsThatCanBeUsedThisEmojiAsReaction == null || emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.length === 0) || ($i && $i.roles.some(r => emoji.roleIdsThatCanBeUsedThisEmojiAsReaction.includes(r.id))); } @@ -305,7 +343,7 @@ function reset() { q.value = ''; } -function getKey(emoji: string | Misskey.entities.CustomEmoji | UnicodeEmojiDef): string { +function getKey(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef): string { return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`; } @@ -329,7 +367,7 @@ function chosen(emoji: any, ev?: MouseEvent) { emit('chosen', key); // 最近使った絵文字更新 - if (!pinned.value.includes(key)) { + if (!pinned.value?.includes(key)) { let recents = defaultStore.state.recentlyUsedEmojis; recents = recents.filter((emoji: any) => emoji !== key); recents.unshift(key); @@ -572,8 +610,7 @@ defineExpose({ position: sticky; top: 0; left: 0; - height: 32px; - line-height: 32px; + line-height: 28px; z-index: 1; padding: 0 8px; font-size: 12px; diff --git a/packages/frontend/src/components/MkEmojiPickerDialog.vue b/packages/frontend/src/components/MkEmojiPickerDialog.vue index 581d815d66..4068a79f08 100644 --- a/packages/frontend/src/components/MkEmojiPickerDialog.vue +++ b/packages/frontend/src/components/MkEmojiPickerDialog.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only ref="modal" v-slot="{ type, maxHeight }" :zPriority="'middle'" - :preferType="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'" + :preferType="defaultStore.state.emojiPickerUseDrawerForMobile === false ? 'popup' : 'auto'" :transparentBg="true" :manualShowing="manualShowing" :src="src" @@ -22,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only class="_popup _shadow" :class="{ [$style.drawer]: type === 'drawer' }" :showPinned="showPinned" + :pinnedEmojis="pinnedEmojis" :asReactionPicker="asReactionPicker" :asDrawer="type === 'drawer'" :max-height="maxHeight" @@ -36,15 +37,19 @@ import MkModal from '@/components/MkModal.vue'; import MkEmojiPicker from '@/components/MkEmojiPicker.vue'; import { defaultStore } from '@/store.js'; -withDefaults(defineProps<{ +const props = withDefaults(defineProps<{ manualShowing?: boolean | null; src?: HTMLElement; showPinned?: boolean; + pinnedEmojis?: string[], asReactionPicker?: boolean; + choseAndClose?: boolean; }>(), { manualShowing: null, showPinned: true, + pinnedEmojis: undefined, asReactionPicker: false, + choseAndClose: true, }); const emit = defineEmits<{ @@ -58,7 +63,9 @@ const picker = shallowRef<InstanceType<typeof MkEmojiPicker>>(); function chosen(emoji: any) { emit('done', emoji); - modal.value?.close(); + if (props.choseAndClose) { + modal.value?.close(); + } } function opening() { diff --git a/packages/frontend/src/components/MkFeaturedPhotos.vue b/packages/frontend/src/components/MkFeaturedPhotos.vue index cef1943d5c..6d1bad7433 100644 --- a/packages/frontend/src/components/MkFeaturedPhotos.vue +++ b/packages/frontend/src/components/MkFeaturedPhotos.vue @@ -12,7 +12,7 @@ import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; -const meta = ref<Misskey.entities.DetailedInstanceMetadata>(); +const meta = ref<Misskey.entities.MetaResponse>(); os.api('meta', { detail: true }).then(gotMeta => { meta.value = gotMeta; diff --git a/packages/frontend/src/components/MkFileCaptionEditWindow.vue b/packages/frontend/src/components/MkFileCaptionEditWindow.vue index b582b88712..b799fb9447 100644 --- a/packages/frontend/src/components/MkFileCaptionEditWindow.vue +++ b/packages/frontend/src/components/MkFileCaptionEditWindow.vue @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; +import { shallowRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -42,12 +42,12 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); -let caption = $ref(props.default); +const caption = ref(props.default); async function ok() { - emit('done', caption); - dialog.close(); + emit('done', caption.value); + dialog.value.close(); } </script> diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 30e93ef9e4..03621a4255 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, onMounted } from 'vue'; +import { nextTick, onMounted, shallowRef, ref } from 'vue'; import { defaultStore } from '@/store.js'; const props = withDefaults(defineProps<{ @@ -70,10 +70,10 @@ const getBgColor = (el: HTMLElement) => { } }; -let rootEl = $shallowRef<HTMLElement>(); -let bgSame = $ref(false); -let opened = $ref(props.defaultOpen); -let openedAtLeastOnce = $ref(props.defaultOpen); +const rootEl = shallowRef<HTMLElement>(); +const bgSame = ref(false); +const opened = ref(props.defaultOpen); +const openedAtLeastOnce = ref(props.defaultOpen); function enter(el) { const elementHeight = el.getBoundingClientRect().height; @@ -98,20 +98,20 @@ function afterLeave(el) { } function toggle() { - if (!opened) { - openedAtLeastOnce = true; + if (!opened.value) { + openedAtLeastOnce.value = true; } nextTick(() => { - opened = !opened; + opened.value = !opened.value; }); } onMounted(() => { const computedStyle = getComputedStyle(document.documentElement); - const parentBg = getBgColor(rootEl.parentElement); + const parentBg = getBgColor(rootEl.value.parentElement); const myBg = computedStyle.getPropertyValue('--panel'); - bgSame = parentBg === myBg; + bgSame.value = parentBg === myBg; }); </script> diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index eebb753db1..d1b1956a03 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onBeforeUnmount, onMounted } from 'vue'; +import { onBeforeUnmount, onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; import { useStream } from '@/stream.js'; @@ -57,9 +57,9 @@ const emit = defineEmits<{ (_: 'update:user', value: Misskey.entities.UserDetailed): void }>(); -let isFollowing = $ref(props.user.isFollowing); -let hasPendingFollowRequestFromYou = $ref(props.user.hasPendingFollowRequestFromYou); -let wait = $ref(false); +const isFollowing = ref(props.user.isFollowing); +const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFromYou); +const wait = ref(false); const connection = useStream().useChannel('main'); if (props.user.isFollowing == null) { @@ -71,16 +71,16 @@ if (props.user.isFollowing == null) { function onFollowChange(user: Misskey.entities.UserDetailed) { if (user.id === props.user.id) { - isFollowing = user.isFollowing; - hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou; + isFollowing.value = user.isFollowing; + hasPendingFollowRequestFromYou.value = user.hasPendingFollowRequestFromYou; } } async function onClick() { - wait = true; + wait.value = true; try { - if (isFollowing) { + if (isFollowing.value) { const { canceled } = await os.confirm({ type: 'warning', text: i18n.t('unfollowConfirm', { name: props.user.name || props.user.username }), @@ -92,11 +92,11 @@ async function onClick() { userId: props.user.id, }); } else { - if (hasPendingFollowRequestFromYou) { + if (hasPendingFollowRequestFromYou.value) { await os.api('following/requests/cancel', { userId: props.user.id, }); - hasPendingFollowRequestFromYou = false; + hasPendingFollowRequestFromYou.value = false; } else { await os.api('following/create', { userId: props.user.id, @@ -104,9 +104,9 @@ async function onClick() { }); emit('update:user', { ...props.user, - withReplies: defaultStore.state.defaultWithReplies + withReplies: defaultStore.state.defaultWithReplies, }); - hasPendingFollowRequestFromYou = true; + hasPendingFollowRequestFromYou.value = true; claimAchievement('following1'); @@ -127,7 +127,7 @@ async function onClick() { } catch (err) { console.error(err); } finally { - wait = false; + wait.value = false; } } diff --git a/packages/frontend/src/components/MkForgotPassword.vue b/packages/frontend/src/components/MkForgotPassword.vue index 521ac11d12..9b57688a02 100644 --- a/packages/frontend/src/components/MkForgotPassword.vue +++ b/packages/frontend/src/components/MkForgotPassword.vue @@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; +import { ref } from 'vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; @@ -53,19 +53,19 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -let dialog: InstanceType<typeof MkModalWindow> = $ref(); +const dialog = ref<InstanceType<typeof MkModalWindow>>(); -let username = $ref(''); -let email = $ref(''); -let processing = $ref(false); +const username = ref(''); +const email = ref(''); +const processing = ref(false); async function onSubmit() { - processing = true; + processing.value = true; await os.apiWithDialog('request-reset-password', { - username, - email, + username: username.value, + email: email.value, }); emit('done'); - dialog.close(); + dialog.value.close(); } </script> diff --git a/packages/frontend/src/components/MkFormDialog.vue b/packages/frontend/src/components/MkFormDialog.vue index 24404728ca..6f882cfab7 100644 --- a/packages/frontend/src/components/MkFormDialog.vue +++ b/packages/frontend/src/components/MkFormDialog.vue @@ -26,11 +26,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template v-if="form[item].description" #caption>{{ form[item].description }}</template> </MkInput> - <MkInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text"> + <MkInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text" :mfmAutocomplete="form[item].treatAsMfm"> <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template v-if="form[item].description" #caption>{{ form[item].description }}</template> </MkInput> - <MkTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]"> + <MkTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]" :mfmAutocomplete="form[item].treatAsMfm" :mfmPreview="form[item].treatAsMfm"> <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ i18n.ts.optional }})</span></template> <template v-if="form[item].description" #caption>{{ form[item].description }}</template> </MkTextarea> diff --git a/packages/frontend/src/components/MkGoogle.vue b/packages/frontend/src/components/MkGoogle.vue index 185a49b5a9..c0b20507fc 100644 --- a/packages/frontend/src/components/MkGoogle.vue +++ b/packages/frontend/src/components/MkGoogle.vue @@ -23,7 +23,7 @@ const query = ref(props.q); const search = () => { const sp = new URLSearchParams(); sp.append('q', query.value); - window.open(`https://www.google.com/search?${sp.toString()}`, '_blank'); + window.open(`https://www.google.com/search?${sp.toString()}`, '_blank', 'noopener'); }; </script> diff --git a/packages/frontend/src/components/MkHeatmap.vue b/packages/frontend/src/components/MkHeatmap.vue index 0022531e58..a57e6c9292 100644 --- a/packages/frontend/src/components/MkHeatmap.vue +++ b/packages/frontend/src/components/MkHeatmap.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, nextTick, watch } from 'vue'; +import { onMounted, nextTick, watch, shallowRef, ref } from 'vue'; import { Chart } from 'chart.js'; import * as os from '@/os.js'; import { defaultStore } from '@/store.js'; @@ -27,11 +27,11 @@ const props = defineProps<{ src: string; }>(); -const rootEl = $shallowRef<HTMLDivElement>(null); -const chartEl = $shallowRef<HTMLCanvasElement>(null); +const rootEl = shallowRef<HTMLDivElement>(null); +const chartEl = shallowRef<HTMLCanvasElement>(null); const now = new Date(); let chartInstance: Chart = null; -let fetching = $ref(true); +const fetching = ref(true); const { handler: externalTooltipHandler } = useChartTooltip({ position: 'middle', @@ -42,8 +42,8 @@ async function renderChart() { chartInstance.destroy(); } - const wide = rootEl.offsetWidth > 700; - const narrow = rootEl.offsetWidth < 400; + const wide = rootEl.value.offsetWidth > 700; + const narrow = rootEl.value.offsetWidth < 400; const weeks = wide ? 50 : narrow ? 10 : 25; const chartLimit = 7 * weeks; @@ -88,7 +88,7 @@ async function renderChart() { values = raw.deliverFailed; } - fetching = false; + fetching.value = false; await nextTick(); @@ -101,7 +101,7 @@ async function renderChart() { const marginEachCell = 4; - chartInstance = new Chart(chartEl, { + chartInstance = new Chart(chartEl.value, { type: 'matrix', data: { datasets: [{ @@ -210,7 +210,7 @@ async function renderChart() { } watch(() => props.src, () => { - fetching = true; + fetching.value = true; renderChart(); }); diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index 4fb573fdbc..942861e1f4 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -21,7 +21,6 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts"> -import { $ref } from 'vue/macros'; import DrawBlurhash from '@/workers/draw-blurhash?worker'; import TestWebGL2 from '@/workers/test-webgl2?worker'; import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch.js'; @@ -58,7 +57,7 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol </script> <script lang="ts" setup> -import { computed, nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue'; +import { computed, nextTick, onMounted, onUnmounted, shallowRef, watch, ref } from 'vue'; import { v4 as uuid } from 'uuid'; import { render } from 'buraha'; import { defaultStore } from '@/store.js'; @@ -98,41 +97,41 @@ const viewId = uuid(); const canvas = shallowRef<HTMLCanvasElement>(); const root = shallowRef<HTMLDivElement>(); const img = shallowRef<HTMLImageElement>(); -let loaded = $ref(false); -let canvasWidth = $ref(64); -let canvasHeight = $ref(64); -let imgWidth = $ref(props.width); -let imgHeight = $ref(props.height); -let bitmapTmp = $ref<CanvasImageSource | undefined>(); -const hide = computed(() => !loaded || props.forceBlurhash); +const loaded = ref(false); +const canvasWidth = ref(64); +const canvasHeight = ref(64); +const imgWidth = ref(props.width); +const imgHeight = ref(props.height); +const bitmapTmp = ref<CanvasImageSource | undefined>(); +const hide = computed(() => !loaded.value || props.forceBlurhash); function waitForDecode() { if (props.src != null && props.src !== '') { nextTick() .then(() => img.value?.decode()) .then(() => { - loaded = true; + loaded.value = true; }, error => { console.log('Error occurred during decoding image', img.value, error); }); } else { - loaded = false; + loaded.value = false; } } watch([() => props.width, () => props.height, root], () => { const ratio = props.width / props.height; if (ratio > 1) { - canvasWidth = Math.round(64 * ratio); - canvasHeight = 64; + canvasWidth.value = Math.round(64 * ratio); + canvasHeight.value = 64; } else { - canvasWidth = 64; - canvasHeight = Math.round(64 / ratio); + canvasWidth.value = 64; + canvasHeight.value = Math.round(64 / ratio); } const clientWidth = root.value?.clientWidth ?? 300; - imgWidth = clientWidth; - imgHeight = Math.round(clientWidth / ratio); + imgWidth.value = clientWidth; + imgHeight.value = Math.round(clientWidth / ratio); }, { immediate: true, }); @@ -140,15 +139,15 @@ watch([() => props.width, () => props.height, root], () => { function drawImage(bitmap: CanvasImageSource) { // canvasがない(mountedされていない)場合はTmpに保存しておく if (!canvas.value) { - bitmapTmp = bitmap; + bitmapTmp.value = bitmap; return; } // canvasがあれば描画する - bitmapTmp = undefined; + bitmapTmp.value = undefined; const ctx = canvas.value.getContext('2d'); if (!ctx) return; - ctx.drawImage(bitmap, 0, 0, canvasWidth, canvasHeight); + ctx.drawImage(bitmap, 0, 0, canvasWidth.value, canvasHeight.value); } function drawAvg() { @@ -160,7 +159,7 @@ function drawAvg() { // avgColorでお茶をにごす ctx.beginPath(); ctx.fillStyle = extractAvgColorFromBlurhash(props.hash) ?? '#888'; - ctx.fillRect(0, 0, canvasWidth, canvasHeight); + ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value); } async function draw() { @@ -212,8 +211,8 @@ watch(() => props.hash, () => { onMounted(() => { // drawImageがmountedより先に呼ばれている場合はここで描画する - if (bitmapTmp) { - drawImage(bitmapTmp); + if (bitmapTmp.value) { + drawImage(bitmapTmp.value); } waitForDecode(); }); diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index 6f237761a8..b4b4e1b0b7 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -43,11 +43,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue'; +import { onMounted, onUnmounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue'; import { debounce } from 'throttle-debounce'; import MkButton from '@/components/MkButton.vue'; import { useInterval } from '@/scripts/use-interval.js'; import { i18n } from '@/i18n.js'; +import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js'; const props = defineProps<{ modelValue: string | number | null; @@ -59,6 +60,7 @@ const props = defineProps<{ placeholder?: string; autofocus?: boolean; autocomplete?: string; + mfmAutocomplete?: boolean | SuggestionType[], autocapitalize?: string; spellcheck?: boolean; step?: any; @@ -93,6 +95,7 @@ const height = props.small ? 33 : props.large ? 39 : 36; +let autocomplete: Autocomplete; const focus = () => inputEl.value.focus(); const onInput = (ev: KeyboardEvent) => { @@ -160,6 +163,16 @@ onMounted(() => { focus(); } }); + + if (props.mfmAutocomplete) { + autocomplete = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? null : props.mfmAutocomplete); + } +}); + +onUnmounted(() => { + if (autocomplete) { + autocomplete.detach(); + } }); defineExpose({ diff --git a/packages/frontend/src/components/MkInstanceCardMini.vue b/packages/frontend/src/components/MkInstanceCardMini.vue index 6af9c6ccb5..9cde197e19 100644 --- a/packages/frontend/src/components/MkInstanceCardMini.vue +++ b/packages/frontend/src/components/MkInstanceCardMini.vue @@ -15,21 +15,22 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkMiniChart from '@/components/MkMiniChart.vue'; import * as os from '@/os.js'; import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; const props = defineProps<{ - instance: Misskey.entities.Instance; + instance: Misskey.entities.FederationInstance; }>(); -let chartValues = $ref<number[] | null>(null); +const chartValues = ref<number[] | null>(null); os.apiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => { // 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く - res.requests.received.splice(0, 1); - chartValues = res.requests.received; + res['requests.received'].splice(0, 1); + chartValues.value = res['requests.received']; }); function getInstanceIcon(instance): string { diff --git a/packages/frontend/src/components/MkInstanceStats.vue b/packages/frontend/src/components/MkInstanceStats.vue index 509254de74..7b763ad385 100644 --- a/packages/frontend/src/components/MkInstanceStats.vue +++ b/packages/frontend/src/components/MkInstanceStats.vue @@ -84,7 +84,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted } from 'vue'; +import { onMounted, ref, shallowRef } from 'vue'; import { Chart } from 'chart.js'; import MkSelect from '@/components/MkSelect.vue'; import MkChart from '@/components/MkChart.vue'; @@ -100,11 +100,11 @@ import { initChart } from '@/scripts/init-chart.js'; initChart(); const chartLimit = 500; -let chartSpan = $ref<'hour' | 'day'>('hour'); -let chartSrc = $ref('active-users'); -let heatmapSrc = $ref('active-users'); -let subDoughnutEl = $shallowRef<HTMLCanvasElement>(); -let pubDoughnutEl = $shallowRef<HTMLCanvasElement>(); +const chartSpan = ref<'hour' | 'day'>('hour'); +const chartSrc = ref('active-users'); +const heatmapSrc = ref('active-users'); +const subDoughnutEl = shallowRef<HTMLCanvasElement>(); +const pubDoughnutEl = shallowRef<HTMLCanvasElement>(); const { handler: externalTooltipHandler1 } = useChartTooltip({ position: 'middle', @@ -163,7 +163,7 @@ function createDoughnut(chartEl, tooltip, data) { onMounted(() => { os.apiGet('federation/stats', { limit: 30 }).then(fedStats => { - createDoughnut(subDoughnutEl, externalTooltipHandler1, fedStats.topSubInstances.map(x => ({ + createDoughnut(subDoughnutEl.value, externalTooltipHandler1, fedStats.topSubInstances.map(x => ({ name: x.host, color: x.themeColor, value: x.followersCount, @@ -172,7 +172,7 @@ onMounted(() => { }, })).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowersCount }])); - createDoughnut(pubDoughnutEl, externalTooltipHandler2, fedStats.topPubInstances.map(x => ({ + createDoughnut(pubDoughnutEl.value, externalTooltipHandler2, fedStats.topPubInstances.map(x => ({ name: x.host, color: x.themeColor, value: x.followingCount, diff --git a/packages/frontend/src/components/MkInstanceTicker.vue b/packages/frontend/src/components/MkInstanceTicker.vue index f0650e48f1..e358a1c549 100644 --- a/packages/frontend/src/components/MkInstanceTicker.vue +++ b/packages/frontend/src/components/MkInstanceTicker.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; +import { computed } from 'vue'; import { instanceName } from '@/config.js'; import { instance as Instance } from '@/instance.js'; import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; @@ -30,7 +30,7 @@ const instance = props.instance ?? { themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content, }; -const faviconUrl = $computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(Instance.faviconUrl, 'preview') ?? '/favicon.ico'); +const faviconUrl = computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(Instance.faviconUrl, 'preview') ?? '/favicon.ico'); const themeColor = instance.themeColor ?? '#777777'; diff --git a/packages/frontend/src/components/MkInviteCode.vue b/packages/frontend/src/components/MkInviteCode.vue index 8e3561e2b8..54d997d1c9 100644 --- a/packages/frontend/src/components/MkInviteCode.vue +++ b/packages/frontend/src/components/MkInviteCode.vue @@ -67,7 +67,7 @@ import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; const props = defineProps<{ - invite: Misskey.entities.Invite; + invite: Misskey.entities.InviteCode; moderator?: boolean; }>(); diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index 17f8af4f63..099082f539 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; +import { shallowRef } from 'vue'; import MkModal from '@/components/MkModal.vue'; import { navbarItemDef } from '@/navbar.js'; import { defaultStore } from '@/store.js'; @@ -48,7 +48,7 @@ const preferedModalType = (deviceKind === 'desktop' && props.src != null) ? 'pop deviceKind === 'smartphone' ? 'drawer' : 'dialog'; -const modal = $shallowRef<InstanceType<typeof MkModal>>(); +const modal = shallowRef<InstanceType<typeof MkModal>>(); const menu = defaultStore.state.menu; @@ -63,7 +63,7 @@ const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => })); function close() { - modal.close(); + modal.value.close(); } </script> @@ -101,6 +101,8 @@ function close() { vertical-align: bottom; height: 100px; border-radius: var(--radius); + padding: 10px; + box-sizing: border-box; &:hover { color: var(--accent); diff --git a/packages/frontend/src/components/MkLink.vue b/packages/frontend/src/components/MkLink.vue index 114b9b4faf..e16307c762 100644 --- a/packages/frontend/src/components/MkLink.vue +++ b/packages/frontend/src/components/MkLink.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <component - :is="self ? 'MkA' : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substring(local.length) : url" :rel="rel" :target="target" + :is="self ? 'MkA' : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substring(local.length) : url" :rel="rel ?? 'nofollow noopener'" :target="target" :title="url" > <slot></slot> @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent } from 'vue'; +import { defineAsyncComponent, ref } from 'vue'; import { url as local } from '@/config.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; import * as os from '@/os.js'; @@ -29,13 +29,13 @@ const self = props.url.startsWith(local); const attr = self ? 'to' : 'href'; const target = self ? null : '_blank'; -const el = $ref(); +const el = ref(); -useTooltip($$(el), (showing) => { +useTooltip(el, (showing) => { os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), { showing, url: props.url, - source: el, + source: el.value, }, {}, 'closed'); }); </script> diff --git a/packages/frontend/src/components/MkMediaBanner.vue b/packages/frontend/src/components/MkMediaBanner.vue index 42a709ae26..4594c8a1db 100644 --- a/packages/frontend/src/components/MkMediaBanner.vue +++ b/packages/frontend/src/components/MkMediaBanner.vue @@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, shallowRef, watch } from 'vue'; +import { shallowRef, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; @@ -42,7 +42,7 @@ const props = withDefaults(defineProps<{ }); const audioEl = shallowRef<HTMLAudioElement>(); -let hide = $ref(true); +const hide = ref(true); watch(audioEl, () => { if (audioEl.value) { diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index 1fa42c1e48..0040f00dc2 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only > <ImgWithBlurhash :hash="image.blurhash" - :src="(defaultStore.state.enableDataSaverMode && hide) ? null : url" + :src="(defaultStore.state.dataSaver.media && hide) ? null : url" :forceBlurhash="hide" :cover="hide || cover" :alt="image.comment || image.name" @@ -32,8 +32,8 @@ SPDX-License-Identifier: AGPL-3.0-only <template v-if="hide"> <div :class="$style.hiddenText"> <div :class="$style.hiddenTextWrapper"> - <b v-if="image.isSensitive" style="display: block;"><i class="ph-eye-closed ph-bold ph-lg"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b> - <b v-else style="display: block;"><i class="ph-image-square ph-bold ph-lg"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b> + <b v-if="image.isSensitive" style="display: block;"><i class="ph-eye-closed ph-bold ph-lg"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ph-image-square ph-bold ph-lg"></i> {{ defaultStore.state.dataSaver.media && image.size ? bytes(image.size) : i18n.ts.image }}</b> <span v-if="controls" style="display: block;">{{ i18n.ts.clickToShow }}</span> </div> </div> @@ -52,7 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { watch } from 'vue'; +import { watch, ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import { getStaticImageUrl } from '@/scripts/media-proxy.js'; import bytes from '@/filters/bytes.js'; @@ -74,10 +74,10 @@ const props = withDefaults(defineProps<{ controls: true, }); -let hide = $ref(true); -let darkMode: boolean = $ref(defaultStore.state.darkMode); +const hide = ref(true); +const darkMode = ref<boolean>(defaultStore.state.darkMode); -const url = $computed(() => (props.raw || defaultStore.state.loadRawImages) +const url = computed(() => (props.raw || defaultStore.state.loadRawImages) ? props.image.url : defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(props.image.url) @@ -88,14 +88,14 @@ function onclick() { if (!props.controls) { return; } - if (hide) { - hide = false; + if (hide.value) { + hide.value = false; } } // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする watch(() => props.image, () => { - hide = (defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore'); + hide.value = (defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore'); }, { deep: true, immediate: true, @@ -106,7 +106,7 @@ function showMenu(ev: MouseEvent) { text: i18n.ts.hide, icon: 'ph-eye-slash ph-bold ph-lg', action: () => { - hide = true; + hide.value = true; }, }, ...(iAmModerator ? [{ text: i18n.ts.markAsSensitive, diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index 610978e4ab..46e32ef2d8 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div ref="root" :class="$style.root"> +<div :class="$style.root"> <XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/> <div v-if="mediaList.filter(media => previewable(media)).length > 0" :class="$style.container"> <div @@ -28,43 +28,8 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> -<script lang="ts"> -/** - * アスペクト比算出のためにHTMLElement.clientWidthを使うが、 - * 大変重たいのでコンテナ要素とメディアリスト幅のペアをキャッシュする - * (タイムラインごとにスクロールコンテナが存在する前提だが……) - */ -const widthCache = new Map<Element, number>(); - -/** - * コンテナ要素がリサイズされたらキャッシュを削除する - */ -const ro = new ResizeObserver(entries => { - for (const entry of entries) { - widthCache.delete(entry.target); - } -}); - -async function getClientWidthWithCache(targetEl: HTMLElement, containerEl: HTMLElement, count = 0) { - if (_DEV_) console.log('getClientWidthWithCache', { targetEl, containerEl, count, cache: widthCache.get(containerEl) }); - if (widthCache.has(containerEl)) return widthCache.get(containerEl)!; - - const width = targetEl.clientWidth; - - if (count <= 10 && width < 64) { - // widthが64未満はおかしいのでリトライする - await new Promise(resolve => setTimeout(resolve, 50)); - return getClientWidthWithCache(targetEl, containerEl, count + 1); - } - - widthCache.set(containerEl, width); - ro.observe(containerEl); - return width; -} -</script> - <script lang="ts" setup> -import { onMounted, onUnmounted, shallowRef } from 'vue'; +import { computed, onMounted, onUnmounted, shallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import PhotoSwipeLightbox from 'photoswipe/lightbox'; import PhotoSwipe from 'photoswipe'; @@ -76,19 +41,16 @@ import XModPlayer from '@/components/MkModPlayer.vue'; import * as os from '@/os.js'; import { FILE_TYPE_BROWSERSAFE, FILE_EXT_TRACKER_MODULES, FILE_TYPE_TRACKER_MODULES } from '@/const.js'; import { defaultStore } from '@/store.js'; -import { getScrollContainer, getBodyScrollHeight } from '@/scripts/scroll.js'; const props = defineProps<{ mediaList: Misskey.entities.DriveFile[]; raw?: boolean; }>(); -const root = shallowRef<HTMLDivElement>(); -const container = shallowRef<HTMLElement | null | undefined>(undefined); const gallery = shallowRef<HTMLDivElement>(); const pswpZIndex = os.claimZIndex('middle'); document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString()); -const count = $computed(() => props.mediaList.filter(media => previewable(media)).length); +const count = computed(() => props.mediaList.filter(media => previewable(media)).length); let lightbox: PhotoSwipeLightbox | null; const popstateHandler = (): void => { @@ -97,12 +59,8 @@ const popstateHandler = (): void => { } }; -/** - * アスペクト比をmediaListWithOneImageAppearanceに基づいていい感じに調整する - * aspect-ratioではなくheightを使う - */ async function calcAspectRatio() { - if (!gallery.value || !root.value) return; + if (!gallery.value) return; let img = props.mediaList[0]; @@ -111,41 +69,22 @@ async function calcAspectRatio() { return; } - if (!container.value) container.value = getScrollContainer(root.value); - const width = container.value ? await getClientWidthWithCache(root.value, container.value) : root.value.clientWidth; - - const heightMin = (ratio: number) => { - const imgResizeRatio = width / img.properties.width; - const imgDrawHeight = img.properties.height * imgResizeRatio; - const maxHeight = width * ratio; - const height = Math.min(imgDrawHeight, maxHeight); - if (_DEV_) console.log('Image height calculated:', { width, properties: img.properties, imgResizeRatio, imgDrawHeight, maxHeight, height }); - return `${height}px`; - }; + const ratioMax = (ratio: number) => `${Math.max(ratio, img.properties.width / img.properties.height).toString()} / 1`; switch (defaultStore.state.mediaListWithOneImageAppearance) { case '16_9': - gallery.value.style.height = heightMin(9 / 16); + gallery.value.style.aspectRatio = ratioMax(16 / 9); break; case '1_1': - gallery.value.style.height = heightMin(1); + gallery.value.style.aspectRatio = ratioMax(1 / 1); break; case '2_3': - gallery.value.style.height = heightMin(3 / 2); + gallery.value.style.aspectRatio = ratioMax(2 / 3); break; - default: { - const maxHeight = Math.max(64, (container.value ? container.value.clientHeight : getBodyScrollHeight()) * 0.5 || 360); - if (width === 0 || !maxHeight) return; - const imgResizeRatio = width / img.properties.width; - const imgDrawHeight = img.properties.height * imgResizeRatio; - gallery.value.style.height = `${Math.max(64, Math.min(imgDrawHeight, maxHeight))}px`; - gallery.value.style.minHeight = 'initial'; - gallery.value.style.maxHeight = 'initial'; + default: + gallery.value.style.aspectRatio = ''; break; - } } - - gallery.value.style.aspectRatio = 'initial'; } const isModule = (file: Misskey.entities.DriveFile): boolean => { diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index 33a9b0fbf9..4f8560f0f1 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -7,8 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="hide" :class="[$style.hidden, (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitiveContainer]" @click="hide = false"> <!-- 【注意】dataSaverMode が有効になっている際には、hide が false になるまでサムネイルや動画を読み込まないようにすること --> <div :class="$style.sensitive"> - <b v-if="video.isSensitive" style="display: block;"><i class="ph-warning ph-bold ph-lg"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> - <b v-else style="display: block;"><i class="ph-film-strip ph-bold ph-lg"></i> {{ defaultStore.state.enableDataSaverMode && video.size ? bytes(video.size) : i18n.ts.video }}</b> + <b v-if="video.isSensitive" style="display: block;"><i class="ph-warning ph-bold ph-lg"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ph-film-strip ph-bold ph-lg"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b> <span>{{ i18n.ts.clickToShow }}</span> </div> </div> @@ -37,18 +37,25 @@ import * as Misskey from 'misskey-js'; import bytes from '@/filters/bytes.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; +import hasAudio from '@/scripts/media-has-audio.js'; const props = defineProps<{ video: Misskey.entities.DriveFile; }>(); -const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore')); +const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore')); const videoEl = shallowRef<HTMLVideoElement>(); watch(videoEl, () => { if (videoEl.value) { videoEl.value.volume = 0.3; + hasAudio(videoEl.value).then(had => { + if (!had) { + videoEl.value.loop = videoEl.value.muted = true; + videoEl.value.play(); + } + }); } }); </script> diff --git a/packages/frontend/src/components/MkMenu.vue b/packages/frontend/src/components/MkMenu.vue index 83f56dc1a2..b0f997a1b9 100644 --- a/packages/frontend/src/components/MkMenu.vue +++ b/packages/frontend/src/components/MkMenu.vue @@ -13,9 +13,9 @@ SPDX-License-Identifier: AGPL-3.0-only @contextmenu.self="e => e.preventDefault()" > <template v-for="(item, i) in items2"> - <div v-if="item === null" role="separator" :class="$style.divider"></div> + <div v-if="item.type === 'divider'" role="separator" :class="$style.divider"></div> <span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]"> - <span>{{ item.text }}</span> + <span style="opacity: 0.7;">{{ item.text }}</span> </span> <span v-else-if="item.type === 'pending'" role="menuitem" :tabindex="i" :class="[$style.pending, $style.item]"> <span><MkEllipsis/></span> @@ -23,32 +23,44 @@ SPDX-License-Identifier: AGPL-3.0-only <MkA v-else-if="item.type === 'link'" role="menuitem" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/> - <span>{{ item.text }}</span> - <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> + <div :class="$style.item_content"> + <span :class="$style.item_content_text">{{ item.text }}</span> + <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> + </div> </MkA> <a v-else-if="item.type === 'a'" role="menuitem" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> - <span>{{ item.text }}</span> - <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> + <div :class="$style.item_content"> + <span :class="$style.item_content_text">{{ item.text }}</span> + <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> + </div> </a> <button v-else-if="item.type === 'user'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/> - <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> + <div v-if="item.indicate" :class="$style.item_content"> + <span :class="$style.indicator"><i class="_indicatorCircle"></i></span> + </div> </button> <button v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" class="_button" :class="[$style.item, $style.switch, { [$style.switchDisabled]: item.disabled } ]" @click="switchItem(item)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <MkSwitchButton :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/> - <span :class="$style.switchText">{{ item.text }}</span> + <div :class="$style.item_content"> + <span :class="[$style.item_content_text, $style.switchText]">{{ item.text }}</span> + </div> </button> <button v-else-if="item.type === 'parent'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showChildren(item, $event)" @click="!preferClick ? null : showChildren(item, $event)"> <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i> - <span style="pointer-events: none;">{{ item.text }}</span> - <span :class="$style.caret" style="pointer-events: none;"><i class="ph-caret-right ph-bold ph-lg ti-fw"></i></span> + <div :class="$style.item_content"> + <span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span> + <span :class="$style.caret" style="pointer-events: none;"><i class="ph-caret-right ph-bold ph-lg ti-fw"></i></span> + </div> </button> <button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)"> <i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i> <MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/> - <span>{{ item.text }}</span> - <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> + <div :class="$style.item_content"> + <span :class="$style.item_content_text">{{ item.text }}</span> + <span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span> + </div> </button> </template> <span v-if="items2.length === 0" :class="[$style.none, $style.item]"> @@ -62,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts"> -import { Ref, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; +import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'; import { focusPrev, focusNext } from '@/scripts/focus.js'; import MkSwitchButton from '@/components/MkSwitch.button.vue'; import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuParent } from '@/types/menu'; @@ -90,19 +102,19 @@ const emit = defineEmits<{ (ev: 'hide'): void; }>(); -let itemsEl = $shallowRef<HTMLDivElement>(); +const itemsEl = shallowRef<HTMLDivElement>(); -let items2: InnerMenuItem[] = $ref([]); +const items2 = ref<InnerMenuItem[]>([]); -let child = $shallowRef<InstanceType<typeof XChild>>(); +const child = shallowRef<InstanceType<typeof XChild>>(); -let keymap = $computed(() => ({ +const keymap = computed(() => ({ 'up|k|shift+tab': focusUp, 'down|j|tab': focusDown, 'esc': close, })); -let childShowingItem = $ref<MenuItem | null>(); +const childShowingItem = ref<MenuItem | null>(); let preferClick = isTouchUsing || props.asDrawer; @@ -115,22 +127,22 @@ watch(() => props.items, () => { if (item && 'then' in item) { // if item is Promise items[i] = { type: 'pending' }; item.then(actualItem => { - items2[i] = actualItem; + items2.value[i] = actualItem; }); } } - items2 = items as InnerMenuItem[]; + items2.value = items as InnerMenuItem[]; }, { immediate: true, }); const childMenu = ref<MenuItem[] | null>(); -let childTarget = $shallowRef<HTMLElement | null>(); +const childTarget = shallowRef<HTMLElement | null>(); function closeChild() { childMenu.value = null; - childShowingItem = null; + childShowingItem.value = null; } function childActioned() { @@ -139,8 +151,8 @@ function childActioned() { } const onGlobalMousedown = (event: MouseEvent) => { - if (childTarget && (event.target === childTarget || childTarget.contains(event.target))) return; - if (child && child.checkHit(event)) return; + if (childTarget.value && (event.target === childTarget.value || childTarget.value.contains(event.target))) return; + if (child.value && child.value.checkHit(event)) return; closeChild(); }; @@ -177,10 +189,10 @@ async function showChildren(item: MenuParent, ev: MouseEvent) { }); emit('hide'); } else { - childTarget = ev.currentTarget ?? ev.target; + childTarget.value = ev.currentTarget ?? ev.target; // これでもリアクティビティは保たれる childMenu.value = children; - childShowingItem = item; + childShowingItem.value = item; } } @@ -202,14 +214,14 @@ function focusDown() { } function switchItem(item: MenuSwitch & { ref: any }) { - if (item.disabled) return; + if (item.disabled !== undefined && (typeof item.disabled === 'boolean' ? item.disabled : item.disabled.value)) return; item.ref = !item.ref; } onMounted(() => { if (props.viaKeyboard) { nextTick(() => { - if (itemsEl) focusNext(itemsEl.children[0], true, false); + if (itemsEl.value) focusNext(itemsEl.value.children[0], true, false); }); } @@ -228,6 +240,7 @@ onBeforeUnmount(() => { .root { padding: 8px 0; box-sizing: border-box; + max-width: 100vw; min-width: 200px; overflow: auto; overscroll-behavior: contain; @@ -267,7 +280,8 @@ onBeforeUnmount(() => { } .item { - display: block; + display: flex; + align-items: center; position: relative; padding: 5px 16px; width: 100%; @@ -340,10 +354,6 @@ onBeforeUnmount(() => { pointer-events: none; font-size: 0.7em; padding-bottom: 4px; - - > span { - opacity: 0.7; - } } &.pending { @@ -373,6 +383,22 @@ onBeforeUnmount(() => { } } +.item_content { + width: 100%; + max-width: 100vw; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + text-overflow: ellipsis; +} + +.item_content_text { + max-width: calc(100vw - 4rem); + text-overflow: ellipsis; + overflow: hidden; +} + .switch { position: relative; display: flex; @@ -406,6 +432,7 @@ onBeforeUnmount(() => { .icon { margin-right: 8px; + line-height: 1; } .caret { @@ -419,9 +446,8 @@ onBeforeUnmount(() => { } .indicator { - position: absolute; - top: 5px; - left: 13px; + display: flex; + align-items: center; color: var(--indicator); font-size: 12px; animation: blink 1s infinite; diff --git a/packages/frontend/src/components/MkMiniChart.vue b/packages/frontend/src/components/MkMiniChart.vue index 8d2a147306..f0a2c232bd 100644 --- a/packages/frontend/src/components/MkMiniChart.vue +++ b/packages/frontend/src/components/MkMiniChart.vue @@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { watch } from 'vue'; +import { watch, ref } from 'vue'; import { v4 as uuid } from 'uuid'; import tinycolor from 'tinycolor2'; import { useInterval } from '@/scripts/use-interval.js'; @@ -43,11 +43,11 @@ const props = defineProps<{ const viewBoxX = 50; const viewBoxY = 50; const gradientId = uuid(); -let polylinePoints = $ref(''); -let polygonPoints = $ref(''); -let headX = $ref<number | null>(null); -let headY = $ref<number | null>(null); -let clock = $ref<number | null>(null); +const polylinePoints = ref(''); +const polygonPoints = ref(''); +const headX = ref<number | null>(null); +const headY = ref<number | null>(null); +const clock = ref<number | null>(null); const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--accent')); const color = accent.toRgbString(); @@ -60,12 +60,12 @@ function draw(): void { (1 - (n / peak)) * viewBoxY, ]); - polylinePoints = _polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); + polylinePoints.value = _polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); - polygonPoints = `0,${ viewBoxY } ${ polylinePoints } ${ viewBoxX },${ viewBoxY }`; + polygonPoints.value = `0,${ viewBoxY } ${ polylinePoints.value } ${ viewBoxX },${ viewBoxY }`; - headX = _polylinePoints.at(-1)![0]; - headY = _polylinePoints.at(-1)![1]; + headX.value = _polylinePoints.at(-1)![0]; + headY.value = _polylinePoints.at(-1)![1]; } watch(() => props.src, draw, { immediate: true }); diff --git a/packages/frontend/src/components/MkModPlayer.vue b/packages/frontend/src/components/MkModPlayer.vue index c24eaab2fa..055522d466 100644 --- a/packages/frontend/src/components/MkModPlayer.vue +++ b/packages/frontend/src/components/MkModPlayer.vue @@ -29,7 +29,7 @@ </template> <script lang="ts" setup> -import { ref, nextTick } from 'vue'; +import { ref, nextTick, computed } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; @@ -71,9 +71,9 @@ const props = defineProps<{ module: Misskey.entities.DriveFile }>(); -const isSensitive = $computed(() => { return props.module.isSensitive; }); -const url = $computed(() => { return props.module.url; }); -let hide = ref((defaultStore.state.nsfw === 'force') ? true : isSensitive && (defaultStore.state.nsfw !== 'ignore')); +const isSensitive = computed(() => { return props.module.isSensitive; }); +const url = computed(() => { return props.module.url; }); +let hide = ref((defaultStore.state.nsfw === 'force') ? true : isSensitive.value && (defaultStore.state.nsfw !== 'ignore')); let playing = ref(false); let displayCanvas = ref<HTMLCanvasElement>(); let progress = ref<HTMLProgressElement>(); @@ -84,7 +84,7 @@ const rowBuffer = 24; let buffer = null; let isSeeking = false; -player.value.load(url).then((result) => { +player.value.load(url.value).then((result) => { buffer = result; try { player.value.play(buffer); diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index ec5039c504..5cd31cdf7c 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -42,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick, normalizeClass, onMounted, onUnmounted, provide, watch } from 'vue'; +import { nextTick, normalizeClass, onMounted, onUnmounted, provide, watch, ref, shallowRef, computed } from 'vue'; import * as os from '@/os.js'; import { isTouchUsing } from '@/scripts/touch.js'; import { defaultStore } from '@/store.js'; @@ -89,14 +89,14 @@ const emit = defineEmits<{ provide('modal', true); -let maxHeight = $ref<number>(); -let fixed = $ref(false); -let transformOrigin = $ref('center'); -let showing = $ref(true); -let content = $shallowRef<HTMLElement>(); +const maxHeight = ref<number>(); +const fixed = ref(false); +const transformOrigin = ref('center'); +const showing = ref(true); +const content = shallowRef<HTMLElement>(); const zIndex = os.claimZIndex(props.zPriority); -let useSendAnime = $ref(false); -const type = $computed<ModalTypes>(() => { +const useSendAnime = ref(false); +const type = computed<ModalTypes>(() => { if (props.preferType === 'auto') { if (!defaultStore.state.disableDrawer && isTouchUsing && deviceKind === 'smartphone') { return 'drawer'; @@ -107,26 +107,26 @@ const type = $computed<ModalTypes>(() => { return props.preferType!; } }); -const isEnableBgTransparent = $computed(() => props.transparentBg && (type === 'popup')); -let transitionName = $computed((() => +const isEnableBgTransparent = computed(() => props.transparentBg && (type.value === 'popup')); +const transitionName = computed((() => defaultStore.state.animation - ? useSendAnime + ? useSendAnime.value ? 'send' - : type === 'drawer' + : type.value === 'drawer' ? 'modal-drawer' - : type === 'popup' + : type.value === 'popup' ? 'modal-popup' : 'modal' : '' )); -let transitionDuration = $computed((() => - transitionName === 'send' +const transitionDuration = computed((() => + transitionName.value === 'send' ? 400 - : transitionName === 'modal-popup' + : transitionName.value === 'modal-popup' ? 100 - : transitionName === 'modal' + : transitionName.value === 'modal' ? 200 - : transitionName === 'modal-drawer' + : transitionName.value === 'modal-drawer' ? 200 : 0 )); @@ -135,12 +135,12 @@ let contentClicking = false; function close(opts: { useSendAnimation?: boolean } = {}) { if (opts.useSendAnimation) { - useSendAnime = true; + useSendAnime.value = true; } // eslint-disable-next-line vue/no-mutating-props if (props.src) props.src.style.pointerEvents = 'auto'; - showing = false; + showing.value = false; emit('close'); } @@ -149,8 +149,8 @@ function onBgClick() { emit('click'); } -if (type === 'drawer') { - maxHeight = window.innerHeight / 1.5; +if (type.value === 'drawer') { + maxHeight.value = window.innerHeight / 1.5; } const keymap = { @@ -162,21 +162,21 @@ const SCROLLBAR_THICKNESS = 16; const align = () => { if (props.src == null) return; - if (type === 'drawer') return; - if (type === 'dialog') return; + if (type.value === 'drawer') return; + if (type.value === 'dialog') return; - if (content == null) return; + if (content.value == null) return; const srcRect = props.src.getBoundingClientRect(); - const width = content!.offsetWidth; - const height = content!.offsetHeight; + const width = content.value!.offsetWidth; + const height = content.value!.offsetHeight; let left; let top; - const x = srcRect.left + (fixed ? 0 : window.pageXOffset); - const y = srcRect.top + (fixed ? 0 : window.pageYOffset); + const x = srcRect.left + (fixed.value ? 0 : window.pageXOffset); + const y = srcRect.top + (fixed.value ? 0 : window.pageYOffset); if (props.anchor.x === 'center') { left = x + (props.src.offsetWidth / 2) - (width / 2); @@ -194,7 +194,7 @@ const align = () => { top = y + props.src.offsetHeight; } - if (fixed) { + if (fixed.value) { // 画面から横にはみ出る場合 if (left + width > (window.innerWidth - SCROLLBAR_THICKNESS)) { left = (window.innerWidth - SCROLLBAR_THICKNESS) - width; @@ -207,16 +207,16 @@ const align = () => { if (top + height > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) { if (props.noOverlap && props.anchor.x === 'center') { if (underSpace >= (upperSpace / 3)) { - maxHeight = underSpace; + maxHeight.value = underSpace; } else { - maxHeight = upperSpace; + maxHeight.value = upperSpace; top = (upperSpace + MARGIN) - height; } } else { top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height; } } else { - maxHeight = underSpace; + maxHeight.value = underSpace; } } else { // 画面から横にはみ出る場合 @@ -231,16 +231,16 @@ const align = () => { if (top + height - window.pageYOffset > ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN)) { if (props.noOverlap && props.anchor.x === 'center') { if (underSpace >= (upperSpace / 3)) { - maxHeight = underSpace; + maxHeight.value = underSpace; } else { - maxHeight = upperSpace; + maxHeight.value = upperSpace; top = window.pageYOffset + ((upperSpace + MARGIN) - height); } } else { top = ((window.innerHeight - SCROLLBAR_THICKNESS) - MARGIN) - height + window.pageYOffset - 1; } } else { - maxHeight = underSpace; + maxHeight.value = underSpace; } } @@ -255,29 +255,29 @@ const align = () => { let transformOriginX = 'center'; let transformOriginY = 'center'; - if (top >= srcRect.top + props.src.offsetHeight + (fixed ? 0 : window.pageYOffset)) { + if (top >= srcRect.top + props.src.offsetHeight + (fixed.value ? 0 : window.pageYOffset)) { transformOriginY = 'top'; - } else if ((top + height) <= srcRect.top + (fixed ? 0 : window.pageYOffset)) { + } else if ((top + height) <= srcRect.top + (fixed.value ? 0 : window.pageYOffset)) { transformOriginY = 'bottom'; } - if (left >= srcRect.left + props.src.offsetWidth + (fixed ? 0 : window.pageXOffset)) { + if (left >= srcRect.left + props.src.offsetWidth + (fixed.value ? 0 : window.pageXOffset)) { transformOriginX = 'left'; - } else if ((left + width) <= srcRect.left + (fixed ? 0 : window.pageXOffset)) { + } else if ((left + width) <= srcRect.left + (fixed.value ? 0 : window.pageXOffset)) { transformOriginX = 'right'; } - transformOrigin = `${transformOriginX} ${transformOriginY}`; + transformOrigin.value = `${transformOriginX} ${transformOriginY}`; - content.style.left = left + 'px'; - content.style.top = top + 'px'; + content.value.style.left = left + 'px'; + content.value.style.top = top + 'px'; }; const onOpened = () => { emit('opened'); // モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する - const el = content!.children[0]; + const el = content.value!.children[0]; el.addEventListener('mousedown', ev => { contentClicking = true; window.addEventListener('mouseup', ev => { @@ -299,7 +299,7 @@ onMounted(() => { // eslint-disable-next-line vue/no-mutating-props props.src.style.pointerEvents = 'none'; } - fixed = (type === 'drawer') || (getFixedContainer(props.src) != null); + fixed.value = (type.value === 'drawer') || (getFixedContainer(props.src) != null); await nextTick(); @@ -307,7 +307,7 @@ onMounted(() => { }, { immediate: true }); nextTick(() => { - alignObserver.observe(content!); + alignObserver.observe(content.value!); }); }); diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index 800950ea82..b91988304d 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted } from 'vue'; +import { onMounted, onUnmounted, shallowRef, ref } from 'vue'; import MkModal from './MkModal.vue'; const props = withDefaults(defineProps<{ @@ -44,14 +44,14 @@ const emit = defineEmits<{ (event: 'ok'): void; }>(); -let modal = $shallowRef<InstanceType<typeof MkModal>>(); -let rootEl = $shallowRef<HTMLElement>(); -let headerEl = $shallowRef<HTMLElement>(); -let bodyWidth = $ref(0); -let bodyHeight = $ref(0); +const modal = shallowRef<InstanceType<typeof MkModal>>(); +const rootEl = shallowRef<HTMLElement>(); +const headerEl = shallowRef<HTMLElement>(); +const bodyWidth = ref(0); +const bodyHeight = ref(0); const close = () => { - modal.close(); + modal.value.close(); }; const onBgClick = () => { @@ -67,14 +67,14 @@ const onKeydown = (evt) => { }; const ro = new ResizeObserver((entries, observer) => { - bodyWidth = rootEl.offsetWidth; - bodyHeight = rootEl.offsetHeight - headerEl.offsetHeight; + bodyWidth.value = rootEl.value.offsetWidth; + bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight; }); onMounted(() => { - bodyWidth = rootEl.offsetWidth; - bodyHeight = rootEl.offsetHeight - headerEl.offsetHeight; - ro.observe(rootEl); + bodyWidth.value = rootEl.value.offsetWidth; + bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight; + ro.observe(rootEl.value); }); onUnmounted(() => { diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 74edc8903e..9ecf21071d 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div - v-if="!muted" + v-if="!hardMuted && !muted" v-show="!isDeleted" ref="el" v-hotkey="keymap" @@ -50,14 +50,14 @@ SPDX-License-Identifier: AGPL-3.0-only <div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div> <MkAvatar :class="$style.avatar" :user="appearNote.user" :link="!mock" :preview="!mock"/> <div :class="[$style.main, { [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined"> - <MkNoteHeader :note="appearNote" :mini="true" v-on:click.stop/> + <MkNoteHeader :note="appearNote" :mini="true" @click.stop/> <MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/> <div style="container-type: inline-size;"> <p v-if="appearNote.cw != null" :class="$style.cw"> <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/> - <MkCwButton v-model="showContent" :note="appearNote" style="margin: 4px 0;" v-on:click.stop/> + <MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;" @click.stop/> </p> - <div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]" > + <div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]"> <div :class="$style.text"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <MkA v-if="appearNote.replyId" :class="$style.replyIcon" :to="`/notes/${appearNote.replyId}`"><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> @@ -79,31 +79,31 @@ SPDX-License-Identifier: AGPL-3.0-only <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> </div> </div> - <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> - <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> + <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> + <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> </div> <div v-if="appearNote.files.length > 0"> - <MkMediaList :mediaList="appearNote.files" v-on:click.stop/> + <MkMediaList :mediaList="appearNote.files" @click.stop/> </div> - <MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll" v-on:click.stop /> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" v-on:click.stop/> + <MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll" @click.stop/> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/> <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> - <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" v-on:click.stop @click="collapsed = false"> + <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false"> <span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span> </button> - <button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" v-on:click.stop @click="collapsed = true"> + <button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click.stop @click="collapsed = true"> <span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span> </button> </div> <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA> </div> - <MkReactionsViewer :note="appearNote" :maxNumber="16" v-on:click.stop @mockUpdateMyReaction="emitUpdReaction"> + <MkReactionsViewer :note="appearNote" :maxNumber="16" @click.stop @mockUpdateMyReaction="emitUpdReaction"> <template #more> <div :class="$style.reactionOmitted">{{ i18n.ts.more }}</div> </template> </MkReactionsViewer> <footer :class="$style.footer"> - <button :class="$style.footerButton" class="_button" v-on:click.stop @click="reply()"> + <button :class="$style.footerButton" class="_button" @click.stop @click="reply()"> <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> <p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ appearNote.repliesCount }}</p> </button> @@ -113,7 +113,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.footerButton" class="_button" :style="renoted ? 'color: var(--accent) !important;' : ''" - v-on:click.stop + @click.stop @mousedown="renoted ? undoRenote(appearNote) : boostVisibility()" > <i class="ph-rocket-launch ph-bold ph-lg"></i> @@ -127,19 +127,19 @@ SPDX-License-Identifier: AGPL-3.0-only ref="quoteButton" :class="$style.footerButton" class="_button" - v-on:click.stop + @click.stop @mousedown="quote()" > <i class="ph-quotes ph-bold ph-lg"></i> </button> - <button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.footerButton" class="_button" v-on:click.stop @click="like()"> + <button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.footerButton" class="_button" @click.stop @click="like()"> <i class="ph-heart ph-bold ph-lg"></i> </button> <button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.footerButton" class="_button" @mousedown="react()"> <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i> </button> - <button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" v-on:click.stop @click="undoReact(appearNote)"> + <button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click.stop @click="undoReact(appearNote)"> <i class="ph-minus ph-bold ph-lg"></i> </button> <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()"> @@ -152,7 +152,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </article> </div> -<div v-else :class="$style.muted" @click="muted = false"> +<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false"> <I18n :src="i18n.ts.userSaysSomething" tag="small"> <template #name> <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> @@ -161,10 +161,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </I18n> </div> +<div v-else> + <!-- + MkDateSeparatedList uses TransitionGroup which requires single element in the child elements + so MkNote create empty div instead of no elements + --> +</div> </template> <script lang="ts" setup> -import { computed, inject, onMounted, ref, shallowRef, Ref, defineAsyncComponent, watch, provide } from 'vue'; +import { computed, inject, onMounted, ref, shallowRef, Ref, watch, provide } from 'vue'; import * as mfm from '@sharkey/sfm-js'; import * as Misskey from 'misskey-js'; import MkNoteSub from '@/components/MkNoteSub.vue'; @@ -183,6 +189,7 @@ import { focusPrev, focusNext } from '@/scripts/focus.js'; import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import * as os from '@/os.js'; +import * as sound from '@/scripts/sound.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; @@ -206,6 +213,7 @@ const props = withDefaults(defineProps<{ note: Misskey.entities.Note; pinned?: boolean; mock?: boolean; + withHardMute?: boolean; }>(), { mock: false, }); @@ -222,7 +230,7 @@ const router = useRouter(); const inChannel = inject('inChannel', null); const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); -let note = $ref(deepClone(props.note)); +const note = ref(deepClone(props.note)); function noteclick(id: string) { const selection = document.getSelection(); @@ -234,7 +242,7 @@ function noteclick(id: string) { // plugin if (noteViewInterruptors.length > 0) { onMounted(async () => { - let result: Misskey.entities.Note | null = deepClone(note); + let result: Misskey.entities.Note | null = deepClone(note.value); for (const interruptor of noteViewInterruptors) { try { result = await interruptor.handler(result); @@ -246,15 +254,16 @@ if (noteViewInterruptors.length > 0) { console.error(err); } } - note = result; + note.value = result; }); } const isRenote = ( - note.renote != null && - note.text == null && - note.fileIds.length === 0 && - note.poll == null + note.value.renote != null && + note.value.text == null && + note.value.cw == null && + note.value.fileIds.length === 0 && + note.value.poll == null ); const el = shallowRef<HTMLElement>(); @@ -266,27 +275,37 @@ const reactButton = shallowRef<HTMLElement>(); const quoteButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); -let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note); -const renoteUrl = appearNote.renote ? appearNote.renote.url : null; -const renoteUri = appearNote.renote ? appearNote.renote.uri : null; +const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); +const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null; +const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null; -const isMyRenote = $i && ($i.id === note.userId); +const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); -const parsed = $computed(() => appearNote.text ? mfm.parse(appearNote.text) : null); -const urls = $computed(() => parsed ? extractUrlFromMfm(parsed).filter(u => u !== renoteUrl && u !== renoteUri) : null); -const animated = $computed(() => parsed ? checkAnimationFromMfm(parsed) : null); -const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); -const isLong = shouldCollapsed(appearNote, urls ?? []); -const collapsed = defaultStore.state.expandLongNote && appearNote.cw == null ? false : ref(appearNote.cw == null && isLong); +const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text).filter(u => u !== renoteUrl && u !== renoteUri) : null); +const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value) : null); +const isLong = shouldCollapsed(appearNote.value, urls.value ?? []); +const collapsed = defaultStore.state.expandLongNote && appearNote.value.cw == null ? false : ref(appearNote.value.cw == null && isLong); const isDeleted = ref(false); const renoted = ref(false); -const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false); +const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); +const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords)); const translation = ref<any>(null); const translating = ref(false); -const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i.id)); -let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null))); +const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i.id)); +const renoteCollapsed = ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || (appearNote.value.myReaction != null))); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); +const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); +const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); + +function checkMute(note: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null): boolean { + if (mutedWords == null) return false; + + if (checkWordMute(note, $i, mutedWords)) return true; + if (note.reply && checkWordMute(note.reply, $i, mutedWords)) return true; + if (note.renote && checkWordMute(note.renote, $i, mutedWords)) return true; + return false; +} const keymap = { 'r': () => reply(true), @@ -301,20 +320,20 @@ const keymap = { provide('react', (reaction: string) => { os.api('notes/reactions/create', { - noteId: appearNote.id, + noteId: appearNote.value.id, reaction: reaction, }); }); if (props.mock) { watch(() => props.note, (to) => { - note = deepClone(to); + note.value = deepClone(to); }, { deep: true }); } else { useNoteCapture({ rootEl: el, - note: $$(appearNote), - pureNote: $$(note), + note: appearNote, + pureNote: note, isDeletedRef: isDeleted, }); } @@ -322,7 +341,7 @@ if (props.mock) { if (!props.mock) { useTooltip(renoteButton, async (showing) => { const renotes = await os.api('notes/renotes', { - noteId: appearNote.id, + noteId: appearNote.value.id, limit: 11, }); @@ -333,14 +352,14 @@ if (!props.mock) { os.popup(MkUsersTooltip, { showing, users, - count: appearNote.renoteCount, + count: appearNote.value.renoteCount, targetElement: renoteButton.value, }, {}, 'closed'); }); useTooltip(quoteButton, async (showing) => { const renotes = await os.api('notes/renotes', { - noteId: appearNote.id, + noteId: appearNote.value.id, limit: 11, quote: true, }); @@ -352,14 +371,14 @@ if (!props.mock) { os.popup(MkUsersTooltip, { showing, users, - count: appearNote.renoteCount, + count: appearNote.value.renoteCount, targetElement: quoteButton.value, }, {}, 'closed'); }); if ($i) { - os.api("notes/renotes", { - noteId: appearNote.id, + os.api('notes/renotes', { + noteId: appearNote.value.id, userId: $i.id, limit: 1, }).then((res) => { @@ -419,7 +438,7 @@ function renote(visibility: Visibility | 'local') { pleaseLogin(); showMovedDialog(); - if (appearNote.channel) { + if (appearNote.value.channel) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -430,14 +449,14 @@ function renote(visibility: Visibility | 'local') { if (!props.mock) { os.api('notes/create', { - renoteId: appearNote.id, - channelId: appearNote.channelId, + renoteId: appearNote.value.id, + channelId: appearNote.value.channelId, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; }); } - } else if (!appearNote.channel || appearNote.channel?.allowRenoteToExternal) { + } else if (!appearNote.value.channel || appearNote.value.channel?.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -449,16 +468,16 @@ function renote(visibility: Visibility | 'local') { const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; const localOnlySetting = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; - let noteVisibility = visibility === 'local' || visibility === 'specified' ? smallerVisibility(appearNote.visibility, configuredVisibility) : smallerVisibility(visibility, configuredVisibility); - if (appearNote.channel?.isSensitive) { - noteVisibility = smallerVisibility(visibility === 'local' || visibility === 'specified' ? appearNote.visibility : visibility, 'home'); + let noteVisibility = visibility === 'local' || visibility === 'specified' ? smallerVisibility(appearNote.value.visibility, configuredVisibility) : smallerVisibility(visibility, configuredVisibility); + if (appearNote.value.channel?.isSensitive) { + noteVisibility = smallerVisibility(visibility === 'local' || visibility === 'specified' ? appearNote.value.visibility : visibility, 'home'); } if (!props.mock) { os.api('notes/create', { localOnly: visibility === 'local' ? true : localOnlySetting, visibility: noteVisibility, - renoteId: appearNote.id, + renoteId: appearNote.value.id, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; @@ -474,13 +493,13 @@ function quote() { return; } - if (appearNote.channel) { + if (appearNote.value.channel) { os.post({ - renote: appearNote, - channel: appearNote.channel, + renote: appearNote.value, + channel: appearNote.value.channel, }).then(() => { - os.api("notes/renotes", { - noteId: appearNote.id, + os.api('notes/renotes', { + noteId: appearNote.value.id, userId: $i.id, limit: 1, quote: true, @@ -499,10 +518,10 @@ function quote() { }); } else { os.post({ - renote: appearNote, + renote: appearNote.value, }).then(() => { - os.api("notes/renotes", { - noteId: appearNote.id, + os.api('notes/renotes', { + noteId: appearNote.value.id, userId: $i.id, limit: 1, quote: true, @@ -528,8 +547,8 @@ function reply(viaKeyboard = false): void { return; } os.post({ - reply: appearNote, - channel: appearNote.channel, + reply: appearNote.value, + channel: appearNote.value.channel, animation: !viaKeyboard, }, () => { focus(); @@ -543,7 +562,7 @@ function like(): void { return; } os.api('notes/like', { - noteId: appearNote.id, + noteId: appearNote.value.id, override: defaultLike.value, }); const el = likeButton.value as HTMLElement | null | undefined; @@ -558,13 +577,15 @@ function like(): void { function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); - if (appearNote.reactionAcceptance === 'likeOnly') { + if (appearNote.value.reactionAcceptance === 'likeOnly') { + sound.play('reaction'); + if (props.mock) { return; } os.api('notes/like', { - noteId: appearNote.id, + noteId: appearNote.value.id, override: defaultLike.value, }); const el = reactButton.value as HTMLElement | null | undefined; @@ -577,16 +598,18 @@ function react(viaKeyboard = false): void { } else { blur(); reactionPicker.show(reactButton.value, reaction => { + sound.play('reaction'); + if (props.mock) { emit('reaction', reaction); return; } os.api('notes/reactions/create', { - noteId: appearNote.id, + noteId: appearNote.value.id, reaction: reaction, }); - if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { + if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) { claimAchievement('reactWithoutRead'); } }, () => { @@ -613,8 +636,8 @@ function undoRenote(note) : void { if (props.mock) { return; } - os.api("notes/unrenote", { - noteId: note.id + os.api('notes/unrenote', { + noteId: note.id, }); os.toast(i18n.ts.rmboost); renoted.value = false; @@ -648,7 +671,7 @@ function onContextmenu(ev: MouseEvent): void { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } @@ -658,14 +681,14 @@ function menu(viaKeyboard = false): void { return; } - const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); os.popupMenu(menu, menuButton.value, { viaKeyboard, }).then(focus).finally(cleanup); } async function menuVersions(viaKeyboard = false): Promise<void> { - const { menu, cleanup } = await getNoteVersionsMenu({ note: note, menuVersionsButton }); + const { menu, cleanup } = await getNoteVersionsMenu({ note: note.value, menuVersionsButton }); os.popupMenu(menu, menuVersionsButton.value, { viaKeyboard, }).then(focus).finally(cleanup); @@ -676,7 +699,7 @@ async function clip() { return; } - os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); + os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); } function showRenoteMenu(viaKeyboard = false): void { @@ -691,7 +714,7 @@ function showRenoteMenu(viaKeyboard = false): void { danger: true, action: () => { os.api('notes/delete', { - noteId: note.id, + noteId: note.value.id, }); isDeleted.value = true; }, @@ -701,17 +724,17 @@ function showRenoteMenu(viaKeyboard = false): void { if (isMyRenote) { pleaseLogin(); os.popupMenu([ - getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote), - null, + getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), + { type: 'divider' }, getUnrenote(), ], renoteTime.value, { viaKeyboard: viaKeyboard, }); } else { os.popupMenu([ - getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote), - null, - getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote), + getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), + { type: 'divider' }, + getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote), $i.isModerator || $i.isAdmin ? getUnrenote() : undefined, ], renoteTime.value, { viaKeyboard: viaKeyboard, @@ -749,7 +772,7 @@ function focusAfter() { function readPromo() { os.api('promo/read', { - noteId: appearNote.id, + noteId: appearNote.value.id, }); isDeleted.value = true; } diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 93e39ff033..f29b9db6ae 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -69,7 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.noteContent"> <p v-if="appearNote.cw != null" :class="$style.cw"> <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/> - <MkCwButton v-model="showContent" :note="appearNote"/> + <MkCwButton v-model="showContent" :text="appearNote.text" :files="appearNote.files" :poll="appearNote.poll"/> </p> <div v-show="appearNote.cw == null || showContent"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> @@ -93,8 +93,8 @@ SPDX-License-Identifier: AGPL-3.0-only <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> </div> </div> - <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> - <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> + <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> + <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> <div v-if="appearNote.files.length > 0"> <MkMediaList :mediaList="appearNote.files"/> </div> @@ -237,6 +237,7 @@ import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import { notePage } from '@/filters/note.js'; import * as os from '@/os.js'; +import * as sound from '@/scripts/sound.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; @@ -248,12 +249,11 @@ import { useNoteCapture } from '@/scripts/use-note-capture.js'; import { deepClone } from '@/scripts/clone.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; import { claimAchievement } from '@/scripts/achievements.js'; -import { MenuItem } from '@/types/menu.js'; import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; -import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; @@ -264,12 +264,12 @@ const props = defineProps<{ const inChannel = inject('inChannel', null); -let note = $ref(deepClone(props.note)); +const note = ref(deepClone(props.note)); // plugin if (noteViewInterruptors.length > 0) { onMounted(async () => { - let result: Misskey.entities.Note | null = deepClone(note); + let result: Misskey.entities.Note | null = deepClone(note.value); for (const interruptor of noteViewInterruptors) { try { result = await interruptor.handler(result); @@ -281,15 +281,15 @@ if (noteViewInterruptors.length > 0) { console.error(err); } } - note = result; + note.value = result; }); } const isRenote = ( - note.renote != null && - note.text == null && - note.fileIds.length === 0 && - note.poll == null + note.value.renote != null && + note.value.text == null && + note.value.fileIds.length === 0 && + note.value.poll == null ); const el = shallowRef<HTMLElement>(); @@ -301,26 +301,25 @@ const reactButton = shallowRef<HTMLElement>(); const quoteButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); -let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note); -const renoteUrl = appearNote.renote ? appearNote.renote.url : null; -const renoteUri = appearNote.renote ? appearNote.renote.uri : null; - -const isMyRenote = $i && ($i.id === note.userId); +const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); +const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null; +const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null; +const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); const isDeleted = ref(false); const renoted = ref(false); -const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false); +const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false); const translation = ref(null); const translating = ref(false); -const parsed = $computed(() => appearNote.text ? mfm.parse(appearNote.text) : null); +const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; const urls = parsed ? extractUrlFromMfm(parsed).filter(u => u !== renoteUrl && u !== renoteUri) : null; -const animated = $computed(() => parsed ? checkAnimationFromMfm(parsed) : null); +const animated = computed(() => parsed ? checkAnimationFromMfm(parsed) : null); const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); -const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); +const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); const conversation = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]); const quotes = ref<Misskey.entities.Note[]>([]); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i.id); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); watch(() => props.expandAllCws, (expandAllCws) => { @@ -328,8 +327,8 @@ watch(() => props.expandAllCws, (expandAllCws) => { }); if ($i) { - os.api("notes/renotes", { - noteId: appearNote.id, + os.api('notes/renotes', { + noteId: appearNote.value.id, userId: $i.id, limit: 1, }).then((res) => { @@ -348,41 +347,41 @@ const keymap = { provide('react', (reaction: string) => { os.api('notes/reactions/create', { - noteId: appearNote.id, + noteId: appearNote.value.id, reaction: reaction, }); }); -let tab = $ref('replies'); -let reactionTabType = $ref(null); +const tab = ref('replies'); +const reactionTabType = ref(null); -const renotesPagination = $computed(() => ({ +const renotesPagination = computed(() => ({ endpoint: 'notes/renotes', limit: 10, params: { - noteId: appearNote.id, + noteId: appearNote.value.id, }, })); -const reactionsPagination = $computed(() => ({ +const reactionsPagination = computed(() => ({ endpoint: 'notes/reactions', limit: 10, params: { - noteId: appearNote.id, - type: reactionTabType, + noteId: appearNote.value.id, + type: reactionTabType.value, }, })); useNoteCapture({ rootEl: el, - note: $$(appearNote), - pureNote: $$(note), + note: appearNote, + pureNote: note, isDeletedRef: isDeleted, }); useTooltip(renoteButton, async (showing) => { const renotes = await os.api('notes/renotes', { - noteId: appearNote.id, + noteId: appearNote.value.id, limit: 11, }); @@ -393,14 +392,14 @@ useTooltip(renoteButton, async (showing) => { os.popup(MkUsersTooltip, { showing, users, - count: appearNote.renoteCount, + count: appearNote.value.renoteCount, targetElement: renoteButton.value, }, {}, 'closed'); }); useTooltip(quoteButton, async (showing) => { const renotes = await os.api('notes/renotes', { - noteId: appearNote.id, + noteId: appearNote.value.id, limit: 11, quote: true, }); @@ -412,7 +411,7 @@ useTooltip(quoteButton, async (showing) => { os.popup(MkUsersTooltip, { showing, users, - count: appearNote.renoteCount, + count: appearNote.value.renoteCount, targetElement: quoteButton.value, }, {}, 'closed'); }); @@ -467,7 +466,7 @@ function renote(visibility: Visibility | 'local') { pleaseLogin(); showMovedDialog(); - if (appearNote.channel) { + if (appearNote.value.channel) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -477,13 +476,13 @@ function renote(visibility: Visibility | 'local') { } os.api('notes/create', { - renoteId: appearNote.id, - channelId: appearNote.channelId, + renoteId: appearNote.value.id, + channelId: appearNote.value.channelId, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; }); - } else if (!appearNote.channel || appearNote.channel?.allowRenoteToExternal) { + } else if (!appearNote.value.channel || appearNote.value.channel?.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -495,15 +494,15 @@ function renote(visibility: Visibility | 'local') { const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; const localOnlySetting = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; - let noteVisibility = visibility === 'local' || visibility === 'specified' ? smallerVisibility(appearNote.visibility, configuredVisibility) : smallerVisibility(visibility, configuredVisibility); - if (appearNote.channel?.isSensitive) { - noteVisibility = smallerVisibility(visibility === 'local' || visibility === 'specified' ? appearNote.visibility : visibility, 'home'); + let noteVisibility = visibility === 'local' || visibility === 'specified' ? smallerVisibility(appearNote.value.visibility, configuredVisibility) : smallerVisibility(visibility, configuredVisibility); + if (appearNote.value.channel?.isSensitive) { + noteVisibility = smallerVisibility(visibility === 'local' || visibility === 'specified' ? appearNote.value.visibility : visibility, 'home'); } os.api('notes/create', { localOnly: visibility === 'local' ? true : localOnlySetting, visibility: noteVisibility, - renoteId: appearNote.id, + renoteId: appearNote.value.id, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; @@ -515,13 +514,13 @@ function quote() { pleaseLogin(); showMovedDialog(); - if (appearNote.channel) { + if (appearNote.value.channel) { os.post({ - renote: appearNote, - channel: appearNote.channel, + renote: appearNote.value, + channel: appearNote.value.channel, }).then(() => { - os.api("notes/renotes", { - noteId: appearNote.id, + os.api('notes/renotes', { + noteId: appearNote.value.id, userId: $i.id, limit: 1, quote: true, @@ -540,10 +539,10 @@ function quote() { }); } else { os.post({ - renote: appearNote, + renote: appearNote.value, }).then(() => { - os.api("notes/renotes", { - noteId: appearNote.id, + os.api('notes/renotes', { + noteId: appearNote.value.id, userId: $i.id, limit: 1, quote: true, @@ -567,8 +566,8 @@ function reply(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); os.post({ - reply: appearNote, - channel: appearNote.channel, + reply: appearNote.value, + channel: appearNote.value.channel, animation: !viaKeyboard, }, () => { focus(); @@ -578,9 +577,9 @@ function reply(viaKeyboard = false): void { function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); - if (appearNote.reactionAcceptance === 'likeOnly') { + if (appearNote.value.reactionAcceptance === 'likeOnly') { os.api('notes/like', { - noteId: appearNote.id, + noteId: appearNote.value.id, override: defaultLike.value, }); const el = reactButton.value as HTMLElement | null | undefined; @@ -593,11 +592,13 @@ function react(viaKeyboard = false): void { } else { blur(); reactionPicker.show(reactButton.value, reaction => { + sound.play('reaction'); + os.api('notes/reactions/create', { - noteId: appearNote.id, + noteId: appearNote.value.id, reaction: reaction, }); - if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { + if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) { claimAchievement('reactWithoutRead'); } }, () => { @@ -610,7 +611,7 @@ function like(): void { pleaseLogin(); showMovedDialog(); os.api('notes/like', { - noteId: appearNote.id, + noteId: appearNote.value.id, override: defaultLike.value, }); const el = likeButton.value as HTMLElement | null | undefined; @@ -632,8 +633,8 @@ function undoReact(note): void { function undoRenote() : void { if (!renoted.value) return; - os.api("notes/unrenote", { - noteId: appearNote.id, + os.api('notes/unrenote', { + noteId: appearNote.value.id, }); os.toast(i18n.ts.rmboost); renoted.value = false; @@ -661,27 +662,27 @@ function onContextmenu(ev: MouseEvent): void { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } function menu(viaKeyboard = false): void { - const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted }); os.popupMenu(menu, menuButton.value, { viaKeyboard, }).then(focus).finally(cleanup); } async function menuVersions(viaKeyboard = false): Promise<void> { - const { menu, cleanup } = await getNoteVersionsMenu({ note: note, menuVersionsButton }); + const { menu, cleanup } = await getNoteVersionsMenu({ note: note.value, menuVersionsButton }); os.popupMenu(menu, menuVersionsButton.value, { viaKeyboard, }).then(focus).finally(cleanup); } async function clip() { - os.popupMenu(await getNoteClipMenu({ note: note, isDeleted }), clipButton.value).then(focus); + os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus); } function showRenoteMenu(viaKeyboard = false): void { @@ -693,7 +694,7 @@ function showRenoteMenu(viaKeyboard = false): void { danger: true, action: () => { os.api('notes/delete', { - noteId: note.id, + noteId: note.value.id, }); isDeleted.value = true; }, @@ -715,7 +716,7 @@ const repliesLoaded = ref(false); function loadReplies() { repliesLoaded.value = true; os.api('notes/children', { - noteId: appearNote.id, + noteId: appearNote.value.id, limit: 30, showQuotes: false, }).then(res => { @@ -730,7 +731,7 @@ const quotesLoaded = ref(false); function loadQuotes() { quotesLoaded.value = true; os.api('notes/renotes', { - noteId: appearNote.id, + noteId: appearNote.value.id, limit: 30, quote: true, }).then(res => { @@ -745,13 +746,13 @@ const conversationLoaded = ref(false); function loadConversation() { conversationLoaded.value = true; os.api('notes/conversation', { - noteId: appearNote.replyId, + noteId: appearNote.value.replyId, }).then(res => { conversation.value = res.reverse(); }); } -if (appearNote.reply && appearNote.reply.replyId && defaultStore.state.autoloadConversation) loadConversation(); +if (appearNote.value.reply && appearNote.value.reply.replyId && defaultStore.state.autoloadConversation) loadConversation(); function animatedMFM() { if (allowAnim.value) { diff --git a/packages/frontend/src/components/MkNotePreview.vue b/packages/frontend/src/components/MkNotePreview.vue index 552f8137ed..c517bc6800 100644 --- a/packages/frontend/src/components/MkNotePreview.vue +++ b/packages/frontend/src/components/MkNotePreview.vue @@ -11,7 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only <MkUserName :user="user" :nowrap="true"/> </div> <div> - <div> + <p v-if="useCw" :class="$style.cw"> + <Mfm v-if="cw != ''" :text="cw" :author="user" :nyaize="'respect'" :i="user" style="margin-right: 8px;"/> + <MkCwButton v-model="showContent" :text="text.trim()" :files="files" :poll="poll" style="margin: 4px 0;"/> + </p> + <div v-show="!useCw || showContent"> <Mfm :text="text.trim()" :author="user" :nyaize="'respect'" :i="user"/> </div> </div> @@ -20,11 +24,23 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; +import { ref } from 'vue'; import * as Misskey from 'misskey-js'; +import MkCwButton from '@/components/MkCwButton.vue'; + +const showContent = ref(false); const props = defineProps<{ text: string; + files: Misskey.entities.DriveFile[]; + poll?: { + choices: string[]; + multiple: boolean; + expiresAt: string | null; + expiredAfter: string | null; + }; + useCw: boolean; + cw: string | null; user: Misskey.entities.User; }>(); </script> @@ -53,6 +69,14 @@ const props = defineProps<{ min-width: 0; } +.cw { + cursor: default; + display: block; + margin: 0; + padding: 0; + overflow-wrap: break-word; +} + .header { margin-bottom: 2px; font-weight: bold; diff --git a/packages/frontend/src/components/MkNoteSimple.vue b/packages/frontend/src/components/MkNoteSimple.vue index bc0f82d44d..7a6109ee0b 100644 --- a/packages/frontend/src/components/MkNoteSimple.vue +++ b/packages/frontend/src/components/MkNoteSimple.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div> <p v-if="note.cw != null" :class="$style.cw"> <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> - <MkCwButton v-model="showContent" :note="note" v-on:click.stop/> + <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/> </p> <div v-show="note.cw == null || showContent"> <MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note"/> @@ -22,12 +22,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { watch } from 'vue'; +import { ref, watch } from 'vue'; import * as Misskey from 'misskey-js'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkCwButton from '@/components/MkCwButton.vue'; -import { $i } from '@/account.js'; import { defaultStore } from '@/store.js'; const props = defineProps<{ @@ -36,10 +35,10 @@ const props = defineProps<{ hideFiles?: boolean; }>(); -let showContent = $ref(defaultStore.state.uncollapseCW); +let showContent = ref(defaultStore.state.uncollapseCW); watch(() => props.expandAllCws, (expandAllCws) => { - if (expandAllCws !== showContent) showContent = expandAllCws; + if (expandAllCws !== showContent.value) showContent.value = expandAllCws; }); </script> diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index 5b1e1af308..8d394c0c15 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.content"> <p v-if="note.cw != null" :class="$style.cw"> <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/> - <MkCwButton v-model="showContent" :note="note"/> + <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/> </p> <div v-show="note.cw == null || showContent"> <MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation"/> @@ -93,15 +93,14 @@ import { notePage } from '@/filters/note.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; -import { userPage } from "@/filters/user.js"; -import { checkWordMute } from "@/scripts/check-word-mute.js"; -import { defaultStore } from "@/store.js"; +import { userPage } from '@/filters/user.js'; +import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { defaultStore } from '@/store.js'; import { pleaseLogin } from '@/scripts/please-login.js'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { reactionPicker } from '@/scripts/reaction-picker.js'; import { claimAchievement } from '@/scripts/achievements.js'; -import type { MenuItem } from '@/types/menu.js'; import { getNoteMenu } from '@/scripts/get-note-menu.js'; import { useNoteCapture } from '@/scripts/use-note-capture.js'; @@ -131,7 +130,7 @@ const quoteButton = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); -let appearNote = $computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note); +let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); const isRenote = ( @@ -143,13 +142,13 @@ const isRenote = ( useNoteCapture({ rootEl: el, - note: $$(appearNote), + note: appearNote, isDeletedRef: isDeleted, }); if ($i) { - os.api("notes/renotes", { - noteId: appearNote.id, + os.api('notes/renotes', { + noteId: appearNote.value.id, userId: $i.id, limit: 1, }).then((res) => { @@ -230,8 +229,8 @@ function undoReact(note): void { function undoRenote() : void { if (!renoted.value) return; - os.api("notes/unrenote", { - noteId: appearNote.id, + os.api('notes/unrenote', { + noteId: appearNote.value.id, }); os.toast(i18n.ts.rmboost); renoted.value = false; @@ -245,13 +244,13 @@ function undoRenote() : void { } } -let showContent = $ref(defaultStore.state.uncollapseCW); +let showContent = ref(defaultStore.state.uncollapseCW); watch(() => props.expandAllCws, (expandAllCws) => { - if (expandAllCws !== showContent) showContent = expandAllCws; + if (expandAllCws !== showContent.value) showContent.value = expandAllCws; }); -let replies: Misskey.entities.Note[] = $ref([]); +let replies = ref<Misskey.entities.Note[]>([]); function boostVisibility() { os.popupMenu([ @@ -293,7 +292,7 @@ function renote(visibility: 'public' | 'home' | 'followers' | 'specified' | 'loc pleaseLogin(); showMovedDialog(); - if (appearNote.channel) { + if (appearNote.value.channel) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -333,12 +332,12 @@ function quote() { pleaseLogin(); showMovedDialog(); - if (appearNote.channel) { + if (appearNote.value.channel) { os.post({ - renote: appearNote, - channel: appearNote.channel, + renote: appearNote.value, + channel: appearNote.value.channel, }).then(() => { - os.api("notes/renotes", { + os.api('notes/renotes', { noteId: props.note.id, userId: $i.id, limit: 1, @@ -358,9 +357,9 @@ function quote() { }); } else { os.post({ - renote: appearNote, + renote: appearNote.value, }).then(() => { - os.api("notes/renotes", { + os.api('notes/renotes', { noteId: props.note.id, userId: $i.id, limit: 1, @@ -394,7 +393,7 @@ if (props.detail) { limit: numberOfReplies.value, showQuotes: false, }).then(res => { - replies = res; + replies.value = res; }); } </script> diff --git a/packages/frontend/src/components/MkNotes.vue b/packages/frontend/src/components/MkNotes.vue index 0d2f0020d1..fc1c8a0f09 100644 --- a/packages/frontend/src/components/MkNotes.vue +++ b/packages/frontend/src/components/MkNotes.vue @@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only :ad="true" :class="$style.notes" > - <MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/> + <MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note" :withHardMute="true"/> </MkDateSeparatedList> <MkDateSeparatedList v-else-if="defaultStore.state.noteDesign === 'sharkey'" diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index ae5be0f2d4..2901139220 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -8,6 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.head"> <MkAvatar v-if="notification.type === 'pollEnded'" :class="$style.icon" :user="notification.note.user" link preview/> <MkAvatar v-else-if="notification.type === 'note'" :class="$style.icon" :user="notification.note.user" link preview/> + <MkAvatar v-else-if="notification.type === 'roleAssigned'" :class="$style.icon" :user="$i" link preview/> <MkAvatar v-else-if="notification.type === 'achievementEarned'" :class="$style.icon" :user="$i" link preview/> <div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div> <div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ph-rocket-launch ph-bold ph-lg" style="line-height: 1;"></i></div> @@ -36,6 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only <i v-else-if="notification.type === 'quote'" class="ph-quotes ph-bold ph-lg"></i> <i v-else-if="notification.type === 'pollEnded'" class="ph-chart-bar-horizontal ph-bold ph-lg"></i> <i v-else-if="notification.type === 'achievementEarned'" class="ph-trophy ph-bold ph-lg"></i> + <img v-else-if="notification.type === 'roleAssigned'" :src="notification.role.iconUrl" alt=""/> <!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 --> <MkReactionIcon v-else-if="notification.type === 'reaction'" @@ -50,6 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only <header :class="$style.header"> <span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span> <span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span> + <span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span> <span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span> <span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span> <MkA v-else-if="notification.user" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA> @@ -86,6 +89,9 @@ SPDX-License-Identifier: AGPL-3.0-only <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/> <i class="ph-quotes ph-bold ph-lg" :class="$style.quote"></i> </MkA> + <div v-else-if="notification.type === 'roleAssigned'" :class="$style.text"> + {{ notification.role.name }} + </div> <MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements"> {{ i18n.ts._achievements._types['_' + notification.achievement].title }} </MkA> @@ -130,7 +136,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, shallowRef } from 'vue'; +import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; diff --git a/packages/frontend/src/components/MkNotificationSelectWindow.vue b/packages/frontend/src/components/MkNotificationSelectWindow.vue index 3d5a56975b..6725776f43 100644 --- a/packages/frontend/src/components/MkNotificationSelectWindow.vue +++ b/packages/frontend/src/components/MkNotificationSelectWindow.vue @@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref, Ref } from 'vue'; +import { ref, Ref, shallowRef } from 'vue'; import MkSwitch from './MkSwitch.vue'; import MkInfo from './MkInfo.vue'; import MkButton from './MkButton.vue'; @@ -51,7 +51,7 @@ const props = withDefaults(defineProps<{ excludeTypes: () => [], }); -const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); const typesMap: TypesMap = notificationTypes.reduce((p, t) => ({ ...p, [t]: ref<boolean>(!props.excludeTypes.includes(t)) }), {} as any); @@ -61,7 +61,7 @@ function ok() { .filter(type => !typesMap[type].value), }); - if (dialog) dialog.close(); + if (dialog.value) dialog.value.close(); } function disableAll() { diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index bfe668a165..a157820d56 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -15,11 +15,11 @@ SPDX-License-Identifier: AGPL-3.0-only <template #default="{ items: notifications }"> <MkDateSeparatedList v-if="defaultStore.state.noteDesign === 'misskey'" v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true"> - <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> + <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" :withHardMute="true"/> <XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel"/> </MkDateSeparatedList> <MkDateSeparatedList v-else-if="defaultStore.state.noteDesign === 'sharkey'" v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true"> - <SkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> + <SkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" :withHardMute="true"/> <XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel"/> </MkDateSeparatedList> </template> @@ -29,13 +29,12 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { onUnmounted, onDeactivated, onMounted, computed, shallowRef, onActivated } from 'vue'; -import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import XNotification from '@/components/MkNotification.vue'; import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue'; import MkNote from '@/components/MkNote.vue'; import SkNote from '@/components/SkNote.vue'; import { useStream } from '@/stream.js'; -import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; import { notificationTypes } from '@/const.js'; import { infoImageUrl } from '@/instance.js'; @@ -48,7 +47,7 @@ const props = defineProps<{ const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); -const pagination: Paging = defaultStore.state.useGroupedNotifications ? { +const pagination = computed(() => defaultStore.reactiveState.useGroupedNotifications.value ? { endpoint: 'i/notifications-grouped' as const, limit: 20, params: computed(() => ({ @@ -60,7 +59,7 @@ const pagination: Paging = defaultStore.state.useGroupedNotifications ? { params: computed(() => ({ excludeTypes: props.excludeTypes ?? undefined, })), -}; +}); function onNotification(notification) { const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false; diff --git a/packages/frontend/src/components/MkOmit.vue b/packages/frontend/src/components/MkOmit.vue index ac957d93dc..702bb95dc7 100644 --- a/packages/frontend/src/components/MkOmit.vue +++ b/packages/frontend/src/components/MkOmit.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted } from 'vue'; +import { onMounted, onUnmounted, shallowRef, ref } from 'vue'; import { i18n } from '@/i18n.js'; const props = withDefaults(defineProps<{ @@ -22,13 +22,13 @@ const props = withDefaults(defineProps<{ maxHeight: 200, }); -let content = $shallowRef<HTMLElement>(); -let omitted = $ref(false); -let ignoreOmit = $ref(false); +const content = shallowRef<HTMLElement>(); +const omitted = ref(false); +const ignoreOmit = ref(false); const calcOmit = () => { - if (omitted || ignoreOmit) return; - omitted = content.offsetHeight > props.maxHeight; + if (omitted.value || ignoreOmit.value) return; + omitted.value = content.value.offsetHeight > props.maxHeight; }; const omitObserver = new ResizeObserver((entries, observer) => { @@ -37,7 +37,7 @@ const omitObserver = new ResizeObserver((entries, observer) => { onMounted(() => { calcOmit(); - omitObserver.observe(content); + omitObserver.observe(content.value); }); onUnmounted(() => { diff --git a/packages/frontend/src/components/MkPagePreview.vue b/packages/frontend/src/components/MkPagePreview.vue index 05b577c49c..6c8a0e56a6 100644 --- a/packages/frontend/src/components/MkPagePreview.vue +++ b/packages/frontend/src/components/MkPagePreview.vue @@ -114,7 +114,6 @@ const props = defineProps<{ & + article { left: 0; - width: 100%; } } } @@ -124,6 +123,7 @@ const props = defineProps<{ > .thumbnail { height: 80px; + overflow: clip; } > article { diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 163cba5e3c..d1d4c2106c 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -29,7 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ComputedRef, onMounted, onUnmounted, provide, shallowRef } from 'vue'; +import { ComputedRef, onMounted, onUnmounted, provide, shallowRef, ref, computed } from 'vue'; import RouterView from '@/components/global/RouterView.vue'; import MkWindow from '@/components/MkWindow.vue'; import { popout as _popout } from '@/scripts/popout.js'; @@ -55,16 +55,16 @@ defineEmits<{ const router = new Router(routes, props.initialPath, !!$i, page(() => import('@/pages/not-found.vue'))); const contents = shallowRef<HTMLElement>(); -let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); -let windowEl = $shallowRef<InstanceType<typeof MkWindow>>(); -const history = $ref<{ path: string; key: any; }[]>([{ +const pageMetadata = ref<null | ComputedRef<PageMetadata>>(); +const windowEl = shallowRef<InstanceType<typeof MkWindow>>(); +const history = ref<{ path: string; key: any; }[]>([{ path: router.getCurrentPath(), key: router.getCurrentKey(), }]); -const buttonsLeft = $computed(() => { +const buttonsLeft = computed(() => { const buttons = []; - if (history.length > 1) { + if (history.value.length > 1) { buttons.push({ icon: 'ph-arrow-left ph-bold ph-lg', onClick: back, @@ -73,7 +73,7 @@ const buttonsLeft = $computed(() => { return buttons; }); -const buttonsRight = $computed(() => { +const buttonsRight = computed(() => { const buttons = [{ icon: 'ph-arrow-clockwise ph-bold ph-lg', title: i18n.ts.reload, @@ -86,22 +86,22 @@ const buttonsRight = $computed(() => { return buttons; }); -let reloadCount = $ref(0); +const reloadCount = ref(0); router.addListener('push', ctx => { - history.push({ path: ctx.path, key: ctx.key }); + history.value.push({ path: ctx.path, key: ctx.key }); }); provide('router', router); provideMetadataReceiver((info) => { - pageMetadata = info; + pageMetadata.value = info; }); provide('shouldOmitHeaderTitle', true); provide('shouldHeaderThin', true); provide('forceSpacerMin', true); provide('shouldBackButton', false); -const contextmenu = $computed(() => ([{ +const contextmenu = computed(() => ([{ icon: 'ph-eject ph-bold ph-lg', text: i18n.ts.showInPage, action: expand, @@ -113,8 +113,8 @@ const contextmenu = $computed(() => ([{ icon: 'ph-arrow-square-out ph-bold ph-lg', text: i18n.ts.openInNewTab, action: () => { - window.open(url + router.getCurrentPath(), '_blank'); - windowEl.close(); + window.open(url + router.getCurrentPath(), '_blank', 'noopener'); + windowEl.value.close(); }, }, { icon: 'ph-link ph-bold ph-lg', @@ -125,26 +125,26 @@ const contextmenu = $computed(() => ([{ }])); function back() { - history.pop(); - router.replace(history.at(-1)!.path, history.at(-1)!.key); + history.value.pop(); + router.replace(history.value.at(-1)!.path, history.value.at(-1)!.key); } function reload() { - reloadCount++; + reloadCount.value++; } function close() { - windowEl.close(); + windowEl.value.close(); } function expand() { mainRouter.push(router.getCurrentPath(), 'forcePage'); - windowEl.close(); + windowEl.value.close(); } function popout() { - _popout(router.getCurrentPath(), windowEl.$el); - windowEl.close(); + _popout(router.getCurrentPath(), windowEl.value.$el); + windowEl.value.close(); } useScrollPositionManager(() => getScrollContainer(contents.value), router); diff --git a/packages/frontend/src/components/MkPagination.vue b/packages/frontend/src/components/MkPagination.vue index e7796dfcb5..07347eda29 100644 --- a/packages/frontend/src/components/MkPagination.vue +++ b/packages/frontend/src/components/MkPagination.vue @@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts"> -import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, watch } from 'vue'; +import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue'; import * as Misskey from 'misskey-js'; import * as os from '@/os.js'; import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll.js'; @@ -105,12 +105,12 @@ const emit = defineEmits<{ (ev: 'status', error: boolean): void; }>(); -let rootEl = $shallowRef<HTMLElement>(); +const rootEl = shallowRef<HTMLElement>(); // 遡り中かどうか -let backed = $ref(false); +const backed = ref(false); -let scrollRemove = $ref<(() => void) | null>(null); +const scrollRemove = ref<(() => void) | null>(null); /** * 表示するアイテムのソース @@ -142,8 +142,8 @@ const { enableInfiniteScroll, } = defaultStore.reactiveState; -const contentEl = $computed(() => props.pagination.pageEl ?? rootEl); -const scrollableElement = $computed(() => contentEl ? getScrollContainer(contentEl) : document.body); +const contentEl = computed(() => props.pagination.pageEl ?? rootEl.value); +const scrollableElement = computed(() => contentEl.value ? getScrollContainer(contentEl.value) : document.body); const visibility = useDocumentVisibility(); @@ -153,40 +153,40 @@ const BACKGROUND_PAUSE_WAIT_SEC = 10; // 先頭が表示されているかどうかを検出 // https://qiita.com/mkataigi/items/0154aefd2223ce23398e -let scrollObserver = $ref<IntersectionObserver>(); +const scrollObserver = ref<IntersectionObserver>(); -watch([() => props.pagination.reversed, $$(scrollableElement)], () => { - if (scrollObserver) scrollObserver.disconnect(); +watch([() => props.pagination.reversed, scrollableElement], () => { + if (scrollObserver.value) scrollObserver.value.disconnect(); - scrollObserver = new IntersectionObserver(entries => { - backed = entries[0].isIntersecting; + scrollObserver.value = new IntersectionObserver(entries => { + backed.value = entries[0].isIntersecting; }, { - root: scrollableElement, + root: scrollableElement.value, rootMargin: props.pagination.reversed ? '-100% 0px 100% 0px' : '100% 0px -100% 0px', threshold: 0.01, }); }, { immediate: true }); -watch($$(rootEl), () => { - scrollObserver?.disconnect(); +watch(rootEl, () => { + scrollObserver.value?.disconnect(); nextTick(() => { - if (rootEl) scrollObserver?.observe(rootEl); + if (rootEl.value) scrollObserver.value?.observe(rootEl.value); }); }); -watch([$$(backed), $$(contentEl)], () => { - if (!backed) { - if (!contentEl) return; +watch([backed, contentEl], () => { + if (!backed.value) { + if (!contentEl.value) return; - scrollRemove = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl, executeQueue, TOLERANCE); + scrollRemove.value = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl.value, executeQueue, TOLERANCE); } else { - if (scrollRemove) scrollRemove(); - scrollRemove = null; + if (scrollRemove.value) scrollRemove.value(); + scrollRemove.value = null; } }); // パラメータに何らかの変更があった際、再読込したい(チャンネル等のIDが変わったなど) -watch(() => props.pagination.params, init, { deep: true }); +watch(() => [props.pagination.endpoint, props.pagination.params], init, { deep: true }); watch(queue, (a, b) => { if (a.size === 0 && b.size === 0) return; @@ -206,6 +206,7 @@ async function init(): Promise<void> { await os.api(props.pagination.endpoint, { ...params, limit: props.pagination.limit ?? 10, + allowPartial: true, }).then(res => { for (let i = 0; i < res.length; i++) { const item = res[i]; @@ -253,14 +254,14 @@ const fetchMore = async (): Promise<void> => { } const reverseConcat = _res => { - const oldHeight = scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight(); - const oldScroll = scrollableElement ? scrollableElement.scrollTop : window.scrollY; + const oldHeight = scrollableElement.value ? scrollableElement.value.scrollHeight : getBodyScrollHeight(); + const oldScroll = scrollableElement.value ? scrollableElement.value.scrollTop : window.scrollY; items.value = concatMapWithArray(items.value, _res); return nextTick(() => { - if (scrollableElement) { - scroll(scrollableElement, { top: oldScroll + (scrollableElement.scrollHeight - oldHeight), behavior: 'instant' }); + if (scrollableElement.value) { + scroll(scrollableElement.value, { top: oldScroll + (scrollableElement.value.scrollHeight - oldHeight), behavior: 'instant' }); } else { window.scroll({ top: oldScroll + (getBodyScrollHeight() - oldHeight), behavior: 'instant' }); } @@ -350,7 +351,7 @@ const appearFetchMoreAhead = async (): Promise<void> => { fetchMoreAppearTimeout(); }; -const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl!, TOLERANCE); +const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl.value!, TOLERANCE); watch(visibility, () => { if (visibility.value === 'hidden') { @@ -444,11 +445,11 @@ onActivated(() => { }); onDeactivated(() => { - isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl ? rootEl.scrollHeight - window.innerHeight : 0) : window.scrollY === 0; + isBackTop.value = props.pagination.reversed ? window.scrollY >= (rootEl.value ? rootEl.value.scrollHeight - window.innerHeight : 0) : window.scrollY === 0; }); function toBottom() { - scrollToBottom(contentEl!); + scrollToBottom(contentEl.value!); } onBeforeMount(() => { @@ -476,13 +477,13 @@ onBeforeUnmount(() => { clearTimeout(preventAppearFetchMoreTimer.value); preventAppearFetchMoreTimer.value = null; } - scrollObserver?.disconnect(); + scrollObserver.value?.disconnect(); }); defineExpose({ items, queue, - backed, + backed: backed.value, more, reload, prepend, diff --git a/packages/frontend/src/components/MkPasswordDialog.vue b/packages/frontend/src/components/MkPasswordDialog.vue index 3f244c42fd..711c54c7f1 100644 --- a/packages/frontend/src/components/MkPasswordDialog.vue +++ b/packages/frontend/src/components/MkPasswordDialog.vue @@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted } from 'vue'; +import { onMounted, shallowRef, ref } from 'vue'; import MkInput from '@/components/MkInput.vue'; import MkButton from '@/components/MkButton.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; @@ -49,22 +49,22 @@ const emit = defineEmits<{ (ev: 'cancelled'): void; }>(); -const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); -const passwordInput = $shallowRef<InstanceType<typeof MkInput>>(); -const password = $ref(''); -const token = $ref(null); +const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const passwordInput = shallowRef<InstanceType<typeof MkInput>>(); +const password = ref(''); +const token = ref(null); function onClose() { emit('cancelled'); - if (dialog) dialog.close(); + if (dialog.value) dialog.value.close(); } function done(res) { - emit('done', { password, token }); - if (dialog) dialog.close(); + emit('done', { password: password.value, token: token.value }); + if (dialog.value) dialog.value.close(); } onMounted(() => { - if (passwordInput) passwordInput.focus(); + if (passwordInput.value) passwordInput.value.focus(); }); </script> diff --git a/packages/frontend/src/components/MkPlusOneEffect.vue b/packages/frontend/src/components/MkPlusOneEffect.vue index 0bc98f4334..a741a3f7a8 100644 --- a/packages/frontend/src/components/MkPlusOneEffect.vue +++ b/packages/frontend/src/components/MkPlusOneEffect.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted } from 'vue'; +import { onMounted, ref } from 'vue'; import * as os from '@/os.js'; const props = withDefaults(defineProps<{ @@ -23,13 +23,13 @@ const emit = defineEmits<{ (ev: 'end'): void; }>(); -let up = $ref(false); +const up = ref(false); const zIndex = os.claimZIndex('middle'); const angle = (45 - (Math.random() * 90)) + 'deg'; onMounted(() => { window.setTimeout(() => { - up = true; + up.value = true; }, 10); window.setTimeout(() => { diff --git a/packages/frontend/src/components/MkPopupMenu.vue b/packages/frontend/src/components/MkPopupMenu.vue index 146b9d7ccf..1d92374f4f 100644 --- a/packages/frontend/src/components/MkPopupMenu.vue +++ b/packages/frontend/src/components/MkPopupMenu.vue @@ -10,10 +10,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { ref } from 'vue'; +import { ref, shallowRef } from 'vue'; import MkModal from './MkModal.vue'; import MkMenu from './MkMenu.vue'; -import { MenuItem } from '@/types/menu'; +import { MenuItem } from '@/types/menu.js'; defineProps<{ items: MenuItem[]; @@ -28,7 +28,7 @@ const emit = defineEmits<{ (ev: 'closing'): void; }>(); -let modal = $shallowRef<InstanceType<typeof MkModal>>(); +const modal = shallowRef<InstanceType<typeof MkModal>>(); const manualShowing = ref(true); const hiding = ref(false); @@ -60,14 +60,14 @@ function hide() { hiding.value = true; // closeは呼ぶ必要がある - modal?.close(); + modal.value?.close(); } function close() { manualShowing.value = false; // closeは呼ぶ必要がある - modal?.close(); + modal.value?.close(); } </script> diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 5c9ac40427..c9784fc40f 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -67,13 +67,14 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInfo v-if="hasNotSpecifiedMentions" warn :class="$style.hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo> <input v-show="useCw" ref="cwInputEl" v-model="cw" :class="$style.cw" :placeholder="i18n.ts.annotation" @keydown="onKeydown"> <div :class="[$style.textOuter, { [$style.withCw]: useCw }]"> - <textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/> + <div v-if="channel" :class="$style.colorBar" :style="{ background: channel.color }"></div> + <textarea ref="textareaEl" v-model="text" :class="[$style.text]" :disabled="posting || posted" :readonly="textAreaReadOnly" :placeholder="placeholder" data-cy-post-form-text @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd"/> <div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div> </div> <input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags"> <XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/> <MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/> - <MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :user="postAccount ?? $i"/> + <MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/> <div v-if="showingOptions" style="padding: 8px 16px;"> </div> <footer :class="$style.footer"> @@ -99,7 +100,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide } from 'vue'; +import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed } from 'vue'; import * as mfm from '@sharkey/sfm-js'; import * as Misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; @@ -125,6 +126,7 @@ import { deepClone } from '@/scripts/clone.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { miLocalStorage } from '@/local-storage.js'; import { claimAchievement } from '@/scripts/achievements.js'; +import { emojiPicker } from '@/scripts/emoji-picker.js'; const modal = inject('modal'); @@ -135,6 +137,7 @@ const props = withDefaults(defineProps<{ mention?: Misskey.entities.User; specified?: Misskey.entities.User; initialText?: string; + initialCw?: string; initialVisibility?: (typeof Misskey.noteVisibilities)[number]; initialFiles?: Misskey.entities.DriveFile[]; initialLocalOnly?: boolean; @@ -144,7 +147,7 @@ const props = withDefaults(defineProps<{ fixed?: boolean; autofocus?: boolean; freezeAfterPosted?: boolean; - editId?: Misskey.entities.Note["id"]; + editId?: Misskey.entities.Note['id']; mock?: boolean; }>(), { initialVisibleUsers: () => [], @@ -163,41 +166,42 @@ const emit = defineEmits<{ (ev: 'fileChangeSensitive', fileId: string, to: boolean): void; }>(); -const textareaEl = $shallowRef<HTMLTextAreaElement | null>(null); -const cwInputEl = $shallowRef<HTMLInputElement | null>(null); -const hashtagsInputEl = $shallowRef<HTMLInputElement | null>(null); -const visibilityButton = $shallowRef<HTMLElement | null>(null); +const textareaEl = shallowRef<HTMLTextAreaElement | null>(null); +const cwInputEl = shallowRef<HTMLInputElement | null>(null); +const hashtagsInputEl = shallowRef<HTMLInputElement | null>(null); +const visibilityButton = shallowRef<HTMLElement | null>(null); -let posting = $ref(false); -let posted = $ref(false); -let text = $ref(props.initialText ?? ''); -let files = $ref(props.initialFiles ?? []); -let poll = $ref<{ +const posting = ref(false); +const posted = ref(false); +const text = ref(props.initialText ?? ''); +const files = ref(props.initialFiles ?? []); +const poll = ref<{ choices: string[]; multiple: boolean; expiresAt: string | null; expiredAfter: string | null; } | null>(null); -let useCw = $ref(false); -let showPreview = $ref(defaultStore.state.showPreview); -watch($$(showPreview), () => defaultStore.set('showPreview', showPreview)); -let cw = $ref<string | null>(null); -let localOnly = $ref<boolean>(props.initialLocalOnly ?? defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly); -let visibility = $ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility) as typeof Misskey.noteVisibilities[number]); -let visibleUsers = $ref([]); +const useCw = ref<boolean>(!!props.initialCw); +const showPreview = ref(defaultStore.state.showPreview); +watch(showPreview, () => defaultStore.set('showPreview', showPreview.value)); +const cw = ref<string | null>(props.initialCw ?? null); +const localOnly = ref<boolean>(props.initialLocalOnly ?? defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly); +const visibility = ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility) as typeof Misskey.noteVisibilities[number]); +const visibleUsers = ref([]); if (props.initialVisibleUsers) { props.initialVisibleUsers.forEach(pushVisibleUser); } -let reactionAcceptance = $ref(defaultStore.state.reactionAcceptance); -let autocomplete = $ref(null); -let draghover = $ref(false); -let quoteId = $ref(null); -let hasNotSpecifiedMentions = $ref(false); -let recentHashtags = $ref(JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]')); -let imeText = $ref(''); -let showingOptions = $ref(false); +const reactionAcceptance = ref(defaultStore.state.reactionAcceptance); +const autocomplete = ref(null); +const draghover = ref(false); +const quoteId = ref(null); +const hasNotSpecifiedMentions = ref(false); +const recentHashtags = ref(JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]')); +const imeText = ref(''); +const showingOptions = ref(false); +const textAreaReadOnly = ref(false); -const draftKey = $computed((): string => { +const draftKey = computed((): string => { let key = props.channel ? `channel:${props.channel.id}` : ''; if (props.renote) { @@ -211,7 +215,7 @@ const draftKey = $computed((): string => { return key; }); -const placeholder = $computed((): string => { +const placeholder = computed((): string => { if (props.renote) { return i18n.ts._postForm.quotePlaceholder; } else if (props.reply) { @@ -231,7 +235,7 @@ const placeholder = $computed((): string => { } }); -const submitText = $computed((): string => { +const submitText = computed((): string => { return props.renote ? i18n.ts.quote : props.reply @@ -239,45 +243,45 @@ const submitText = $computed((): string => { : i18n.ts.note; }); -const textLength = $computed((): number => { - return (text + imeText).trim().length; +const textLength = computed((): number => { + return (text.value + imeText.value).trim().length; }); -const maxTextLength = $computed((): number => { +const maxTextLength = computed((): number => { return instance ? instance.maxNoteTextLength : 1000; }); -const canPost = $computed((): boolean => { - return !props.mock && !posting && !posted && - (1 <= textLength || 1 <= files.length || !!poll || !!props.renote) && - (textLength <= maxTextLength) && - (!poll || poll.choices.length >= 2); +const canPost = computed((): boolean => { + return !props.mock && !posting.value && !posted.value && + (1 <= textLength.value || 1 <= files.value.length || !!poll.value || !!props.renote) && + (textLength.value <= maxTextLength.value) && + (!poll.value || poll.value.choices.length >= 2); }); -const withHashtags = $computed(defaultStore.makeGetterSetter('postFormWithHashtags')); -const hashtags = $computed(defaultStore.makeGetterSetter('postFormHashtags')); +const withHashtags = computed(defaultStore.makeGetterSetter('postFormWithHashtags')); +const hashtags = computed(defaultStore.makeGetterSetter('postFormHashtags')); -watch($$(text), () => { +watch(text, () => { checkMissingMention(); }, { immediate: true }); -watch($$(visibility), () => { +watch(visibility, () => { checkMissingMention(); }, { immediate: true }); -watch($$(visibleUsers), () => { +watch(visibleUsers, () => { checkMissingMention(); }, { deep: true, }); if (props.mention) { - text = props.mention.host ? `@${props.mention.username}@${toASCII(props.mention.host)}` : `@${props.mention.username}`; - text += ' '; + text.value = props.mention.host ? `@${props.mention.username}@${toASCII(props.mention.host)}` : `@${props.mention.username}`; + text.value += ' '; } if (props.reply && (props.reply.user.username !== $i.username || (props.reply.user.host != null && props.reply.user.host !== host))) { - text = `@${props.reply.user.username}${props.reply.user.host != null ? '@' + toASCII(props.reply.user.host) : ''} `; + text.value = `@${props.reply.user.username}${props.reply.user.host != null ? '@' + toASCII(props.reply.user.host) : ''} `; } if (props.reply && props.reply.text != null) { @@ -295,32 +299,32 @@ if (props.reply && props.reply.text != null) { if ($i.username === x.username && (x.host == null || x.host === host)) continue; // 重複は除外 - if (text.includes(`${mention} `)) continue; + if (text.value.includes(`${mention} `)) continue; - text += `${mention} `; + text.value += `${mention} `; } } -if ($i?.isSilenced && visibility === 'public') { - visibility = 'home'; +if ($i?.isSilenced && visibility.value === 'public') { + visibility.value = 'home'; } if (props.channel) { - visibility = 'public'; - localOnly = true; // TODO: チャンネルが連合するようになった折には消す + visibility.value = 'public'; + localOnly.value = true; // TODO: チャンネルが連合するようになった折には消す } // 公開以外へのリプライ時は元の公開範囲を引き継ぐ if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visibility)) { - if (props.reply.visibility === 'home' && visibility === 'followers') { - visibility = 'followers'; - } else if (['home', 'followers'].includes(props.reply.visibility) && visibility === 'specified') { - visibility = 'specified'; + if (props.reply.visibility === 'home' && visibility.value === 'followers') { + visibility.value = 'followers'; + } else if (['home', 'followers'].includes(props.reply.visibility) && visibility.value === 'specified') { + visibility.value = 'specified'; } else { - visibility = props.reply.visibility; + visibility.value = props.reply.visibility; } - if (visibility === 'specified') { + if (visibility.value === 'specified') { if (props.reply.visibleUserIds) { os.api('users/show', { userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId), @@ -338,24 +342,24 @@ if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visib } if (props.specified) { - visibility = 'specified'; + visibility.value = 'specified'; pushVisibleUser(props.specified); } // keep cw when reply if (defaultStore.state.keepCw && props.reply && props.reply.cw) { - useCw = true; - cw = props.reply.cw; + useCw.value = true; + cw.value = props.reply.cw; } function watchForDraft() { - watch($$(text), () => saveDraft()); - watch($$(useCw), () => saveDraft()); - watch($$(cw), () => saveDraft()); - watch($$(poll), () => saveDraft()); - watch($$(files), () => saveDraft(), { deep: true }); - watch($$(visibility), () => saveDraft()); - watch($$(localOnly), () => saveDraft()); + watch(text, () => saveDraft()); + watch(useCw, () => saveDraft()); + watch(cw, () => saveDraft()); + watch(poll, () => saveDraft()); + watch(files, () => saveDraft(), { deep: true }); + watch(visibility, () => saveDraft()); + watch(localOnly, () => saveDraft()); } function MFMWindow() { @@ -363,36 +367,36 @@ function MFMWindow() { } function checkMissingMention() { - if (visibility === 'specified') { - const ast = mfm.parse(text); + if (visibility.value === 'specified') { + const ast = mfm.parse(text.value); for (const x of extractMentions(ast)) { - if (!visibleUsers.some(u => (u.username === x.username) && (u.host === x.host))) { - hasNotSpecifiedMentions = true; + if (!visibleUsers.value.some(u => (u.username === x.username) && (u.host === x.host))) { + hasNotSpecifiedMentions.value = true; return; } } - hasNotSpecifiedMentions = false; } + hasNotSpecifiedMentions.value = false; } function addMissingMention() { - const ast = mfm.parse(text); + const ast = mfm.parse(text.value); for (const x of extractMentions(ast)) { - if (!visibleUsers.some(u => (u.username === x.username) && (u.host === x.host))) { + if (!visibleUsers.value.some(u => (u.username === x.username) && (u.host === x.host))) { os.api('users/show', { username: x.username, host: x.host }).then(user => { - visibleUsers.push(user); + visibleUsers.value.push(user); }); } } } function togglePoll() { - if (poll) { - poll = null; + if (poll.value) { + poll.value = null; } else { - poll = { + poll.value = { choices: ['', ''], multiple: false, expiresAt: null, @@ -402,13 +406,13 @@ function togglePoll() { } function addTag(tag: string) { - insertTextAtCursor(textareaEl, ` #${tag} `); + insertTextAtCursor(textareaEl.value, ` #${tag} `); } function focus() { - if (textareaEl) { - textareaEl.focus(); - textareaEl.setSelectionRange(textareaEl.value.length, textareaEl.value.length); + if (textareaEl.value) { + textareaEl.value.focus(); + textareaEl.value.setSelectionRange(textareaEl.value.value.length, textareaEl.value.value.length); } } @@ -417,55 +421,55 @@ function chooseFileFrom(ev) { selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => { for (const file of files_) { - files.push(file); + files.value.push(file); } }); } function detachFile(id) { - files = files.filter(x => x.id !== id); + files.value = files.value.filter(x => x.id !== id); } function updateFileSensitive(file, sensitive) { if (props.mock) { emit('fileChangeSensitive', file.id, sensitive); } - files[files.findIndex(x => x.id === file.id)].isSensitive = sensitive; + files.value[files.value.findIndex(x => x.id === file.id)].isSensitive = sensitive; } function updateFileName(file, name) { - files[files.findIndex(x => x.id === file.id)].name = name; + files.value[files.value.findIndex(x => x.id === file.id)].name = name; } function replaceFile(file: Misskey.entities.DriveFile, newFile: Misskey.entities.DriveFile): void { - files[files.findIndex(x => x.id === file.id)] = newFile; + files.value[files.value.findIndex(x => x.id === file.id)] = newFile; } function upload(file: File, name?: string): void { if (props.mock) return; uploadFile(file, defaultStore.state.uploadFolder, name).then(res => { - files.push(res); + files.value.push(res); }); } function setVisibility() { if (props.channel) { - visibility = 'public'; - localOnly = true; // TODO: チャンネルが連合するようになった折には消す + visibility.value = 'public'; + localOnly.value = true; // TODO: チャンネルが連合するようになった折には消す return; } os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), { - currentVisibility: visibility, + currentVisibility: visibility.value, isSilenced: $i?.isSilenced, - localOnly: localOnly, - src: visibilityButton, + localOnly: localOnly.value, + src: visibilityButton.value, }, { changeVisibility: v => { - visibility = v; + visibility.value = v; if (defaultStore.state.rememberNoteVisibility) { - defaultStore.set('visibility', visibility); + defaultStore.set('visibility', visibility.value); } }, }, 'closed'); @@ -473,14 +477,14 @@ function setVisibility() { async function toggleLocalOnly() { if (props.channel) { - visibility = 'public'; - localOnly = true; // TODO: チャンネルが連合するようになった折には消す + visibility.value = 'public'; + localOnly.value = true; // TODO: チャンネルが連合するようになった折には消す return; } const neverShowInfo = miLocalStorage.getItem('neverShowLocalOnlyInfo'); - if (!localOnly && neverShowInfo !== 'true') { + if (!localOnly.value && neverShowInfo !== 'true') { const confirm = await os.actions({ type: 'question', title: i18n.ts.disableFederationConfirm, @@ -510,7 +514,7 @@ async function toggleLocalOnly() { } } - localOnly = !localOnly; + localOnly.value = !localOnly.value; } async function toggleReactionAcceptance() { @@ -523,15 +527,15 @@ async function toggleReactionAcceptance() { { value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' as const, text: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote }, { value: 'likeOnly' as const, text: i18n.ts.likeOnly }, ], - default: reactionAcceptance, + default: reactionAcceptance.value, }); if (select.canceled) return; - reactionAcceptance = select.result; + reactionAcceptance.value = select.result; } function pushVisibleUser(user) { - if (!visibleUsers.some(u => u.username === user.username && u.host === user.host)) { - visibleUsers.push(user); + if (!visibleUsers.value.some(u => u.username === user.username && u.host === user.host)) { + visibleUsers.value.push(user); } } @@ -539,34 +543,34 @@ function addVisibleUser() { os.selectUser().then(user => { pushVisibleUser(user); - if (!text.toLowerCase().includes(`@${user.username.toLowerCase()}`)) { - text = `@${Misskey.acct.toString(user)} ${text}`; + if (!text.value.toLowerCase().includes(`@${user.username.toLowerCase()}`)) { + text.value = `@${Misskey.acct.toString(user)} ${text.value}`; } }); } function removeVisibleUser(user) { - visibleUsers = erase(user, visibleUsers); + visibleUsers.value = erase(user, visibleUsers.value); } function clear() { - text = ''; - files = []; - poll = null; - quoteId = null; + text.value = ''; + files.value = []; + poll.value = null; + quoteId.value = null; } function onKeydown(ev: KeyboardEvent) { - if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey) && canPost) post(); + if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey) && canPost.value) post(); if (ev.key === 'Escape') emit('esc'); } function onCompositionUpdate(ev: CompositionEvent) { - imeText = ev.data; + imeText.value = ev.data; } function onCompositionEnd(ev: CompositionEvent) { - imeText = ''; + imeText.value = ''; } async function onPaste(ev: ClipboardEvent) { @@ -584,7 +588,7 @@ async function onPaste(ev: ClipboardEvent) { const paste = ev.clipboardData.getData('text'); - if (!props.renote && !quoteId && paste.startsWith(url + '/notes/')) { + if (!props.renote && !quoteId.value && paste.startsWith(url + '/notes/')) { ev.preventDefault(); os.confirm({ @@ -592,11 +596,11 @@ async function onPaste(ev: ClipboardEvent) { text: i18n.ts.quoteQuestion, }).then(({ canceled }) => { if (canceled) { - insertTextAtCursor(textareaEl, paste); + insertTextAtCursor(textareaEl.value, paste); return; } - quoteId = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)[1]; + quoteId.value = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)[1]; }); } } @@ -607,7 +611,7 @@ function onDragover(ev) { const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; if (isFile || isDriveFile) { ev.preventDefault(); - draghover = true; + draghover.value = true; switch (ev.dataTransfer.effectAllowed) { case 'all': case 'uninitialized': @@ -628,15 +632,15 @@ function onDragover(ev) { } function onDragenter(ev) { - draghover = true; + draghover.value = true; } function onDragleave(ev) { - draghover = false; + draghover.value = false; } function onDrop(ev): void { - draghover = false; + draghover.value = false; // ファイルだったら if (ev.dataTransfer.files.length > 0) { @@ -649,7 +653,7 @@ function onDrop(ev): void { const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); if (driveFile != null && driveFile !== '') { const file = JSON.parse(driveFile); - files.push(file); + files.value.push(file); ev.preventDefault(); } //#endregion @@ -660,16 +664,16 @@ function saveDraft() { const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}'); - draftData[draftKey] = { + draftData[draftKey.value] = { updatedAt: new Date(), data: { - text: text, - useCw: useCw, - cw: cw, - visibility: visibility, - localOnly: localOnly, - files: files, - poll: poll, + text: text.value, + useCw: useCw.value, + cw: cw.value, + visibility: visibility.value, + localOnly: localOnly.value, + files: files.value, + poll: poll.value, }, }; @@ -679,13 +683,13 @@ function saveDraft() { function deleteDraft() { const draftData = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}'); - delete draftData[draftKey]; + delete draftData[draftKey.value]; miLocalStorage.setItem('drafts', JSON.stringify(draftData)); } async function post(ev?: MouseEvent) { - if (useCw && (cw == null || cw.trim() === '')) { + if (useCw.value && (cw.value == null || cw.value.trim() === '')) { os.alert({ type: 'error', text: i18n.ts.cwNotationRequired, @@ -704,13 +708,13 @@ async function post(ev?: MouseEvent) { if (props.mock) return; const annoying = - text.includes('$[x2') || - text.includes('$[x3') || - text.includes('$[x4') || - text.includes('$[scale') || - text.includes('$[position'); + text.value.includes('$[x2') || + text.value.includes('$[x3') || + text.value.includes('$[x4') || + text.value.includes('$[scale') || + text.value.includes('$[position'); - if (annoying && visibility === 'public') { + if (annoying && visibility.value === 'public') { const { canceled, result } = await os.actions({ type: 'warning', text: i18n.ts.thisPostMayBeAnnoying, @@ -730,27 +734,27 @@ async function post(ev?: MouseEvent) { if (canceled) return; if (result === 'cancel') return; if (result === 'home') { - visibility = 'home'; + visibility.value = 'home'; } } let postData = { - text: text === '' ? null : text, - fileIds: files.length > 0 ? files.map(f => f.id) : undefined, + text: text.value === '' ? null : text.value, + fileIds: files.value.length > 0 ? files.value.map(f => f.id) : undefined, replyId: props.reply ? props.reply.id : undefined, - renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined, + renoteId: props.renote ? props.renote.id : quoteId.value ? quoteId.value : undefined, channelId: props.channel ? props.channel.id : undefined, - poll: poll, - cw: useCw ? cw ?? '' : null, - localOnly: localOnly, - visibility: visibility, - visibleUserIds: visibility === 'specified' ? visibleUsers.map(u => u.id) : undefined, - reactionAcceptance, + poll: poll.value, + cw: useCw.value ? cw.value ?? '' : null, + localOnly: localOnly.value, + visibility: visibility.value, + visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(u => u.id) : undefined, + reactionAcceptance: reactionAcceptance.value, editId: props.editId ? props.editId : undefined, }; - if (withHashtags && hashtags && hashtags.trim() !== '') { - const hashtags_ = hashtags.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' '); + if (withHashtags.value && hashtags.value && hashtags.value.trim() !== '') { + const hashtags_ = hashtags.value.trim().split(' ').map(x => x.startsWith('#') ? x : '#' + x).join(' '); postData.text = postData.text ? `${postData.text} ${hashtags_}` : hashtags_; } @@ -767,15 +771,15 @@ async function post(ev?: MouseEvent) { let token = undefined; - if (postAccount) { + if (postAccount.value) { const storedAccounts = await getAccounts(); - token = storedAccounts.find(x => x.id === postAccount.id)?.token; + token = storedAccounts.find(x => x.id === postAccount.value.id)?.token; } - posting = true; - os.api(postData.editId ? "notes/edit" : "notes/create", postData, token).then(() => { + posting.value = true; + os.api(postData.editId ? 'notes/edit' : 'notes/create', postData, token).then(() => { if (props.freezeAfterPosted) { - posted = true; + posted.value = true; } else { clear(); } @@ -787,8 +791,8 @@ async function post(ev?: MouseEvent) { const history = JSON.parse(miLocalStorage.getItem('hashtags') ?? '[]') as string[]; miLocalStorage.setItem('hashtags', JSON.stringify(unique(hashtags_.concat(history)))); } - posting = false; - postAccount = null; + posting.value = false; + postAccount.value = null; incNotesCount(); if (notesCount === 1) { @@ -833,7 +837,7 @@ async function post(ev?: MouseEvent) { } }); }).catch(err => { - posting = false; + posting.value = false; os.alert({ type: 'error', text: err.message + '\n' + (err as any).id, @@ -847,12 +851,23 @@ function cancel() { function insertMention() { os.selectUser().then(user => { - insertTextAtCursor(textareaEl, '@' + Misskey.acct.toString(user) + ' '); + insertTextAtCursor(textareaEl.value, '@' + Misskey.acct.toString(user) + ' '); }); } async function insertEmoji(ev: MouseEvent) { - os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textareaEl); + textAreaReadOnly.value = true; + + emojiPicker.show( + ev.currentTarget ?? ev.target, + emoji => { + insertTextAtCursor(textareaEl.value, emoji); + }, + () => { + textAreaReadOnly.value = false; + nextTick(() => focus()); + }, + ); } function showActions(ev) { @@ -860,17 +875,17 @@ function showActions(ev) { text: action.title, action: () => { action.handler({ - text: text, - cw: cw, + text: text.value, + cw: cw.value, }, (key, value) => { - if (key === 'text') { text = value; } - if (key === 'cw') { useCw = value !== null; cw = value; } + if (key === 'text') { text.value = value; } + if (key === 'cw') { useCw.value = value !== null; cw.value = value; } }); }, })), ev.currentTarget ?? ev.target); } -let postAccount = $ref<Misskey.entities.UserDetailed | null>(null); +const postAccount = ref<Misskey.entities.UserDetailed | null>(null); function openAccountMenu(ev: MouseEvent) { if (props.mock) return; @@ -878,12 +893,12 @@ function openAccountMenu(ev: MouseEvent) { openAccountMenu_({ withExtraOperation: false, includeCurrentAccount: true, - active: postAccount != null ? postAccount.id : $i.id, + active: postAccount.value != null ? postAccount.value.id : $i.id, onChoose: (account) => { if (account.id === $i.id) { - postAccount = null; + postAccount.value = null; } else { - postAccount = account; + postAccount.value = account; } }, }, ev); @@ -899,23 +914,23 @@ onMounted(() => { } // TODO: detach when unmount - new Autocomplete(textareaEl, $$(text)); - new Autocomplete(cwInputEl, $$(cw)); - new Autocomplete(hashtagsInputEl, $$(hashtags)); + new Autocomplete(textareaEl.value, text); + new Autocomplete(cwInputEl.value, cw); + new Autocomplete(hashtagsInputEl.value, hashtags); nextTick(() => { // 書きかけの投稿を復元 if (!props.instant && !props.mention && !props.specified && !props.mock) { - const draft = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}')[draftKey]; + const draft = JSON.parse(miLocalStorage.getItem('drafts') ?? '{}')[draftKey.value]; if (draft) { - text = draft.data.text; - useCw = draft.data.useCw; - cw = draft.data.cw; - visibility = draft.data.visibility; - localOnly = draft.data.localOnly; - files = (draft.data.files || []).filter(draftFile => draftFile); + text.value = draft.data.text; + useCw.value = draft.data.useCw; + cw.value = draft.data.cw; + visibility.value = draft.data.visibility; + localOnly.value = draft.data.localOnly; + files.value = (draft.data.files || []).filter(draftFile => draftFile); if (draft.data.poll) { - poll = draft.data.poll; + poll.value = draft.data.poll; } } } @@ -923,21 +938,21 @@ onMounted(() => { // 削除して編集 if (props.initialNote) { const init = props.initialNote; - text = init.text ? init.text : ''; - files = init.files; - cw = init.cw; - useCw = init.cw != null; + text.value = init.text ? init.text : ''; + files.value = init.files; + cw.value = init.cw; + useCw.value = init.cw != null; if (init.poll) { - poll = { + poll.value = { choices: init.poll.choices.map(x => x.text), multiple: init.poll.multiple, expiresAt: init.poll.expiresAt ? new Date(init.poll.expiresAt).getTime().toString() : null, expiredAfter: init.poll.expiredAfter ? new Date(init.poll.expiredAfter).getTime().toString() : null, }; } - visibility = init.visibility; - localOnly = init.localOnly; - quoteId = init.renote ? init.renote.id : null; + visibility.value = init.visibility; + localOnly.value = init.localOnly; + quoteId.value = init.renote ? init.renote.id : null; } nextTick(() => watchForDraft()); @@ -1031,6 +1046,16 @@ defineExpose({ } } +.colorBar { + position: absolute; + top: 0px; + left: 12px; + width: 5px; + height: 100% ; + border-radius: 999px; + pointer-events: none; +} + .submitInner { padding: 0 12px; line-height: 34px; @@ -1066,8 +1091,9 @@ defineExpose({ .visibility { overflow: clip; - text-overflow: ellipsis; - white-space: nowrap; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 210px; &:enabled { > .headerRightButtonText { @@ -1288,5 +1314,6 @@ defineExpose({ .headerRight { gap: 0; } + } </style> diff --git a/packages/frontend/src/components/MkPostFormDialog.vue b/packages/frontend/src/components/MkPostFormDialog.vue index 25a8788a38..cd25077bfb 100644 --- a/packages/frontend/src/components/MkPostFormDialog.vue +++ b/packages/frontend/src/components/MkPostFormDialog.vue @@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; +import { shallowRef } from 'vue'; import * as Misskey from 'misskey-js'; import MkModal from '@/components/MkModal.vue'; import MkPostForm from '@/components/MkPostForm.vue'; @@ -22,6 +22,7 @@ const props = defineProps<{ mention?: Misskey.entities.User; specified?: Misskey.entities.User; initialText?: string; + initialCw?: string; initialVisibility?: typeof Misskey.noteVisibilities; initialFiles?: Misskey.entities.DriveFile[]; initialLocalOnly?: boolean; @@ -37,11 +38,11 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -let modal = $shallowRef<InstanceType<typeof MkModal>>(); -let form = $shallowRef<InstanceType<typeof MkPostForm>>(); +const modal = shallowRef<InstanceType<typeof MkModal>>(); +const form = shallowRef<InstanceType<typeof MkPostForm>>(); function onPosted() { - modal.close({ + modal.value.close({ useSendAnimation: true, }); } diff --git a/packages/frontend/src/components/MkPullToRefresh.vue b/packages/frontend/src/components/MkPullToRefresh.vue index 9f50f7ad5d..e963697997 100644 --- a/packages/frontend/src/components/MkPullToRefresh.vue +++ b/packages/frontend/src/components/MkPullToRefresh.vue @@ -23,8 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, watch } from 'vue'; -import { deviceKind } from '@/scripts/device-kind.js'; +import { onMounted, onUnmounted, ref, shallowRef } from 'vue'; import { i18n } from '@/i18n.js'; import { getScrollContainer } from '@/scripts/scroll.js'; @@ -35,15 +34,15 @@ const RELEASE_TRANSITION_DURATION = 200; const PULL_BRAKE_BASE = 1.5; const PULL_BRAKE_FACTOR = 170; -let isPullStart = $ref(false); -let isPullEnd = $ref(false); -let isRefreshing = $ref(false); -let pullDistance = $ref(0); +const isPullStart = ref(false); +const isPullEnd = ref(false); +const isRefreshing = ref(false); +const pullDistance = ref(0); let supportPointerDesktop = false; let startScreenY: number | null = null; -const rootEl = $shallowRef<HTMLDivElement>(); +const rootEl = shallowRef<HTMLDivElement>(); let scrollEl: HTMLElement | null = null; let disabled = false; @@ -66,17 +65,17 @@ function getScreenY(event) { } function moveStart(event) { - if (!isPullStart && !isRefreshing && !disabled) { - isPullStart = true; + if (!isPullStart.value && !isRefreshing.value && !disabled) { + isPullStart.value = true; startScreenY = getScreenY(event); - pullDistance = 0; + pullDistance.value = 0; } } function moveBySystem(to: number): Promise<void> { return new Promise(r => { - const startHeight = pullDistance; - const overHeight = pullDistance - to; + const startHeight = pullDistance.value; + const overHeight = pullDistance.value - to; if (overHeight < 1) { r(); return; @@ -85,36 +84,36 @@ function moveBySystem(to: number): Promise<void> { let intervalId = setInterval(() => { const time = Date.now() - startTime; if (time > RELEASE_TRANSITION_DURATION) { - pullDistance = to; + pullDistance.value = to; clearInterval(intervalId); r(); return; } const nextHeight = startHeight - (overHeight / RELEASE_TRANSITION_DURATION) * time; - if (pullDistance < nextHeight) return; - pullDistance = nextHeight; + if (pullDistance.value < nextHeight) return; + pullDistance.value = nextHeight; }, 1); }); } async function fixOverContent() { - if (pullDistance > FIRE_THRESHOLD) { + if (pullDistance.value > FIRE_THRESHOLD) { await moveBySystem(FIRE_THRESHOLD); } } async function closeContent() { - if (pullDistance > 0) { + if (pullDistance.value > 0) { await moveBySystem(0); } } function moveEnd() { - if (isPullStart && !isRefreshing) { + if (isPullStart.value && !isRefreshing.value) { startScreenY = null; - if (isPullEnd) { - isPullEnd = false; - isRefreshing = true; + if (isPullEnd.value) { + isPullEnd.value = false; + isRefreshing.value = true; fixOverContent().then(() => { emit('refresh'); props.refresher().then(() => { @@ -122,17 +121,17 @@ function moveEnd() { }); }); } else { - closeContent().then(() => isPullStart = false); + closeContent().then(() => isPullStart.value = false); } } } function moving(event: TouchEvent | PointerEvent) { - if (!isPullStart || isRefreshing || disabled) return; + if (!isPullStart.value || isRefreshing.value || disabled) return; - if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance)) { - pullDistance = 0; - isPullEnd = false; + if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance.value)) { + pullDistance.value = 0; + isPullEnd.value = false; moveEnd(); return; } @@ -143,13 +142,13 @@ function moving(event: TouchEvent | PointerEvent) { const moveScreenY = getScreenY(event); const moveHeight = moveScreenY - startScreenY!; - pullDistance = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE); + pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE); - if (pullDistance > 0) { + if (pullDistance.value > 0) { if (event.cancelable) event.preventDefault(); } - isPullEnd = pullDistance >= FIRE_THRESHOLD; + isPullEnd.value = pullDistance.value >= FIRE_THRESHOLD; } /** @@ -159,8 +158,8 @@ function moving(event: TouchEvent | PointerEvent) { */ function refreshFinished() { closeContent().then(() => { - isPullStart = false; - isRefreshing = false; + isPullStart.value = false; + isRefreshing.value = false; }); } @@ -182,26 +181,26 @@ function onScrollContainerScroll() { } function registerEventListenersForReadyToPull() { - if (rootEl == null) return; - rootEl.addEventListener('touchstart', moveStart, { passive: true }); - rootEl.addEventListener('touchmove', moving, { passive: false }); // passive: falseにしないとpreventDefaultが使えない + if (rootEl.value == null) return; + rootEl.value.addEventListener('touchstart', moveStart, { passive: true }); + rootEl.value.addEventListener('touchmove', moving, { passive: false }); // passive: falseにしないとpreventDefaultが使えない } function unregisterEventListenersForReadyToPull() { - if (rootEl == null) return; - rootEl.removeEventListener('touchstart', moveStart); - rootEl.removeEventListener('touchmove', moving); + if (rootEl.value == null) return; + rootEl.value.removeEventListener('touchstart', moveStart); + rootEl.value.removeEventListener('touchmove', moving); } onMounted(() => { - if (rootEl == null) return; + if (rootEl.value == null) return; - scrollEl = getScrollContainer(rootEl); + scrollEl = getScrollContainer(rootEl.value); if (scrollEl == null) return; scrollEl.addEventListener('scroll', onScrollContainerScroll, { passive: true }); - rootEl.addEventListener('touchend', moveEnd, { passive: true }); + rootEl.value.addEventListener('touchend', moveEnd, { passive: true }); registerEventListenersForReadyToPull(); }); diff --git a/packages/frontend/src/components/MkPushNotificationAllowButton.vue b/packages/frontend/src/components/MkPushNotificationAllowButton.vue index ba64775298..ebbd5e6cdc 100644 --- a/packages/frontend/src/components/MkPushNotificationAllowButton.vue +++ b/packages/frontend/src/components/MkPushNotificationAllowButton.vue @@ -41,6 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script setup lang="ts"> +import { ref } from 'vue'; import { $i, getAccounts } from '@/account.js'; import MkButton from '@/components/MkButton.vue'; import { instance } from '@/instance.js'; @@ -62,26 +63,26 @@ defineProps<{ }>(); // ServiceWorker registration -let registration = $ref<ServiceWorkerRegistration | undefined>(); +const registration = ref<ServiceWorkerRegistration | undefined>(); // If this browser supports push notification -let supported = $ref(false); +const supported = ref(false); // If this browser has already subscribed to push notification -let pushSubscription = $ref<PushSubscription | null>(null); -let pushRegistrationInServer = $ref<{ state?: string; key?: string; userId: string; endpoint: string; sendReadMessage: boolean; } | undefined>(); +const pushSubscription = ref<PushSubscription | null>(null); +const pushRegistrationInServer = ref<{ state?: string; key?: string; userId: string; endpoint: string; sendReadMessage: boolean; } | undefined>(); function subscribe() { - if (!registration || !supported || !instance.swPublickey) return; + if (!registration.value || !supported.value || !instance.swPublickey) return; // SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters - return promiseDialog(registration.pushManager.subscribe({ + return promiseDialog(registration.value.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(instance.swPublickey), }) .then(async subscription => { - pushSubscription = subscription; + pushSubscription.value = subscription; // Register - pushRegistrationInServer = await api('sw/register', { + pushRegistrationInServer.value = await api('sw/register', { endpoint: subscription.endpoint, auth: encode(subscription.getKey('auth')), publickey: encode(subscription.getKey('p256dh')), @@ -102,12 +103,12 @@ function subscribe() { } async function unsubscribe() { - if (!pushSubscription) return; + if (!pushSubscription.value) return; - const endpoint = pushSubscription.endpoint; + const endpoint = pushSubscription.value.endpoint; const accounts = await getAccounts(); - pushRegistrationInServer = undefined; + pushRegistrationInServer.value = undefined; if ($i && accounts.length >= 2) { apiWithDialog('sw/unregister', { @@ -115,11 +116,11 @@ async function unsubscribe() { endpoint, }); } else { - pushSubscription.unsubscribe(); + pushSubscription.value.unsubscribe(); apiWithDialog('sw/unregister', { endpoint, }); - pushSubscription = null; + pushSubscription.value = null; } } @@ -150,20 +151,20 @@ if (navigator.serviceWorker == null) { // TODO: よしなに? } else { navigator.serviceWorker.ready.then(async swr => { - registration = swr; + registration.value = swr; - pushSubscription = await registration.pushManager.getSubscription(); + pushSubscription.value = await registration.value.pushManager.getSubscription(); if (instance.swPublickey && ('PushManager' in window) && $i && $i.token) { - supported = true; + supported.value = true; - if (pushSubscription) { + if (pushSubscription.value) { const res = await api('sw/show-registration', { - endpoint: pushSubscription.endpoint, + endpoint: pushSubscription.value.endpoint, }); if (res) { - pushRegistrationInServer = res; + pushRegistrationInServer.value = res; } } } @@ -171,6 +172,6 @@ if (navigator.serviceWorker == null) { } defineExpose({ - pushRegistrationInServer: $$(pushRegistrationInServer), + pushRegistrationInServer: pushRegistrationInServer, }); </script> diff --git a/packages/frontend/src/components/MkRadio.vue b/packages/frontend/src/components/MkRadio.vue index f22774ef97..edb3abe5f7 100644 --- a/packages/frontend/src/components/MkRadio.vue +++ b/packages/frontend/src/components/MkRadio.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; +import { computed } from 'vue'; const props = defineProps<{ modelValue: any; @@ -36,7 +36,7 @@ const emit = defineEmits<{ (ev: 'update:modelValue', value: any): void; }>(); -let checked = $computed(() => props.modelValue === props.value); +const checked = computed(() => props.modelValue === props.value); function toggle(): void { if (props.disabled) return; diff --git a/packages/frontend/src/components/MkReactionEffect.vue b/packages/frontend/src/components/MkReactionEffect.vue index 88e262d880..75eb91e7ad 100644 --- a/packages/frontend/src/components/MkReactionEffect.vue +++ b/packages/frontend/src/components/MkReactionEffect.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted } from 'vue'; +import { onMounted, ref } from 'vue'; import * as os from '@/os.js'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; @@ -27,13 +27,13 @@ const emit = defineEmits<{ (ev: 'end'): void; }>(); -let up = $ref(false); +const up = ref(false); const zIndex = os.claimZIndex('middle'); const angle = (90 - (Math.random() * 180)) + 'deg'; onMounted(() => { window.setTimeout(() => { - up = true; + up.value = true; }, 10); window.setTimeout(() => { diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 42b5243e94..e7901316a2 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.small]: defaultStore.state.reactionsDisplaySize === 'small', [$style.large]: defaultStore.state.reactionsDisplaySize === 'large' }]" @click="toggleReaction()" > - <MkReactionIcon :class="$style.icon" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]" @click="toggleReaction()"/> + <MkReactionIcon :class="defaultStore.state.limitWidthOfReaction ? $style.limitWidth : ''" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]" @click="toggleReaction()"/> <span :class="$style.count">{{ count }}</span> </button> </template> @@ -28,6 +28,7 @@ import MkReactionEffect from '@/components/MkReactionEffect.vue'; import { claimAchievement } from '@/scripts/achievements.js'; import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; +import * as sound from '@/scripts/sound.js'; const props = defineProps<{ reaction: string; @@ -59,6 +60,10 @@ async function toggleReaction() { }); if (confirm.canceled) return; + if (oldReaction !== props.reaction) { + sound.play('reaction'); + } + if (mock) { emit('reactionToggled', props.reaction, (props.count - 1)); return; @@ -75,6 +80,8 @@ async function toggleReaction() { } }); } else { + sound.play('reaction'); + if (mock) { emit('reactionToggled', props.reaction, (props.count + 1)); return; @@ -132,12 +139,14 @@ if (!mock) { <style lang="scss" module> .root { - display: inline-block; + display: inline-flex; height: 42px; margin: 2px; padding: 0 6px; font-size: 1.5em; border-radius: var(--radius-sm); + align-items: center; + justify-content: center; &.canToggle { background: var(--buttonBg); @@ -176,7 +185,7 @@ if (!mock) { &.reacted, &.reacted:hover { background: var(--accentedBg); color: var(--accent); - box-shadow: 0 0 0px 1px var(--accent) inset; + box-shadow: 0 0 0 1px var(--accent) inset; > .count { color: var(--accent); @@ -188,7 +197,7 @@ if (!mock) { } } -.icon { +.limitWidth { max-width: 150px; } diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index 13d022977e..d2a5c431fe 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { inject, watch } from 'vue'; +import { inject, watch, ref } from 'vue'; import XReaction from '@/components/MkReactionsViewer.reaction.vue'; import { defaultStore } from '@/store.js'; @@ -38,31 +38,31 @@ const emit = defineEmits<{ const initialReactions = new Set(Object.keys(props.note.reactions)); -let reactions = $ref<[string, number][]>([]); -let hasMoreReactions = $ref(false); +const reactions = ref<[string, number][]>([]); +const hasMoreReactions = ref(false); -if (props.note.myReaction && !Object.keys(reactions).includes(props.note.myReaction)) { - reactions[props.note.myReaction] = props.note.reactions[props.note.myReaction]; +if (props.note.myReaction && !Object.keys(reactions.value).includes(props.note.myReaction)) { + reactions.value[props.note.myReaction] = props.note.reactions[props.note.myReaction]; } function onMockToggleReaction(emoji: string, count: number) { if (!mock) return; - const i = reactions.findIndex((item) => item[0] === emoji); + const i = reactions.value.findIndex((item) => item[0] === emoji); if (i < 0) return; - emit('mockUpdateMyReaction', emoji, (count - reactions[i][1])); + emit('mockUpdateMyReaction', emoji, (count - reactions.value[i][1])); } watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumber]) => { let newReactions: [string, number][] = []; - hasMoreReactions = Object.keys(newSource).length > maxNumber; + hasMoreReactions.value = Object.keys(newSource).length > maxNumber; - for (let i = 0; i < reactions.length; i++) { - const reaction = reactions[i][0]; + for (let i = 0; i < reactions.value.length; i++) { + const reaction = reactions.value[i][0]; if (reaction in newSource && newSource[reaction] !== 0) { - reactions[i][1] = newSource[reaction]; - newReactions.push(reactions[i]); + reactions.value[i][1] = newSource[reaction]; + newReactions.push(reactions.value[i]); } } @@ -80,7 +80,7 @@ watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumbe newReactions.push([props.note.myReaction, newSource[props.note.myReaction]]); } - reactions = newReactions; + reactions.value = newReactions; }, { immediate: true, deep: true }); </script> diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue index 3dc9a94ae2..e69aa1be80 100644 --- a/packages/frontend/src/components/MkRetentionHeatmap.vue +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, nextTick } from 'vue'; +import { onMounted, nextTick, shallowRef, ref } from 'vue'; import { Chart } from 'chart.js'; import * as os from '@/os.js'; import { defaultStore } from '@/store.js'; @@ -23,11 +23,11 @@ import { initChart } from '@/scripts/init-chart.js'; initChart(); -const rootEl = $shallowRef<HTMLDivElement>(null); -const chartEl = $shallowRef<HTMLCanvasElement>(null); +const rootEl = shallowRef<HTMLDivElement>(null); +const chartEl = shallowRef<HTMLCanvasElement>(null); const now = new Date(); let chartInstance: Chart = null; -let fetching = $ref(true); +const fetching = ref(true); const { handler: externalTooltipHandler } = useChartTooltip({ position: 'middle', @@ -38,8 +38,8 @@ async function renderChart() { chartInstance.destroy(); } - const wide = rootEl.offsetWidth > 600; - const narrow = rootEl.offsetWidth < 400; + const wide = rootEl.value.offsetWidth > 600; + const narrow = rootEl.value.offsetWidth < 400; const maxDays = wide ? 10 : narrow ? 5 : 7; @@ -66,7 +66,7 @@ async function renderChart() { } } - fetching = false; + fetching.value = false; await nextTick(); @@ -83,7 +83,7 @@ async function renderChart() { const marginEachCell = 12; - chartInstance = new Chart(chartEl, { + chartInstance = new Chart(chartEl.value, { type: 'matrix', data: { datasets: [{ diff --git a/packages/frontend/src/components/MkSignin.vue b/packages/frontend/src/components/MkSignin.vue index a8718b98d6..08830fca7a 100644 --- a/packages/frontend/src/components/MkSignin.vue +++ b/packages/frontend/src/components/MkSignin.vue @@ -49,7 +49,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent } from 'vue'; +import { defineAsyncComponent, ref } from 'vue'; import { toUnicode } from 'punycode/'; import * as Misskey from 'misskey-js'; import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill'; @@ -62,17 +62,17 @@ import * as os from '@/os.js'; import { login } from '@/account.js'; import { i18n } from '@/i18n.js'; -let signing = $ref(false); -let user = $ref<Misskey.entities.UserDetailed | null>(null); -let username = $ref(''); -let password = $ref(''); -let token = $ref(''); -let host = $ref(toUnicode(configHost)); -let totpLogin = $ref(false); -let queryingKey = $ref(false); -let credentialRequest = $ref<CredentialRequestOptions | null>(null); -let hCaptchaResponse = $ref(null); -let reCaptchaResponse = $ref(null); +const signing = ref(false); +const user = ref<Misskey.entities.UserDetailed | null>(null); +const username = ref(''); +const password = ref(''); +const token = ref(''); +const host = ref(toUnicode(configHost)); +const totpLogin = ref(false); +const queryingKey = ref(false); +const credentialRequest = ref<CredentialRequestOptions | null>(null); +const hCaptchaResponse = ref(null); +const reCaptchaResponse = ref(null); const emit = defineEmits<{ (ev: 'login', v: any): void; @@ -98,11 +98,11 @@ const props = defineProps({ function onUsernameChange(): void { os.api('users/show', { - username: username, + username: username.value, }).then(userResponse => { - user = userResponse; + user.value = userResponse; }, () => { - user = null; + user.value = null; }); } @@ -113,21 +113,21 @@ function onLogin(res: any): Promise<void> | void { } async function queryKey(): Promise<void> { - queryingKey = true; - await webAuthnRequest(credentialRequest) + queryingKey.value = true; + await webAuthnRequest(credentialRequest.value) .catch(() => { - queryingKey = false; + queryingKey.value = false; return Promise.reject(null); }).then(credential => { - credentialRequest = null; - queryingKey = false; - signing = true; + credentialRequest.value = null; + queryingKey.value = false; + signing.value = true; return os.api('signin', { - username, - password, + username: username.value, + password: password.value, credential: credential.toJSON(), - 'hcaptcha-response': hCaptchaResponse, - 'g-recaptcha-response': reCaptchaResponse, + 'hcaptcha-response': hCaptchaResponse.value, + 'g-recaptcha-response': reCaptchaResponse.value, }); }).then(res => { emit('login', res); @@ -138,39 +138,39 @@ async function queryKey(): Promise<void> { type: 'error', text: i18n.ts.signinFailed, }); - signing = false; + signing.value = false; }); } function onSubmit(): void { - signing = true; - if (!totpLogin && user && user.twoFactorEnabled) { - if (webAuthnSupported() && user.securityKeys) { + signing.value = true; + if (!totpLogin.value && user.value && user.value.twoFactorEnabled) { + if (webAuthnSupported() && user.value.securityKeys) { os.api('signin', { - username, - password, - 'hcaptcha-response': hCaptchaResponse, - 'g-recaptcha-response': reCaptchaResponse, + username: username.value, + password: password.value, + 'hcaptcha-response': hCaptchaResponse.value, + 'g-recaptcha-response': reCaptchaResponse.value, }).then(res => { - totpLogin = true; - signing = false; - credentialRequest = parseRequestOptionsFromJSON({ + totpLogin.value = true; + signing.value = false; + credentialRequest.value = parseRequestOptionsFromJSON({ publicKey: res, }); }) .then(() => queryKey()) .catch(loginFailed); } else { - totpLogin = true; - signing = false; + totpLogin.value = true; + signing.value = false; } } else { os.api('signin', { - username, - password, - 'hcaptcha-response': hCaptchaResponse, - 'g-recaptcha-response': reCaptchaResponse, - token: user?.twoFactorEnabled ? token : undefined, + username: username.value, + password: password.value, + 'hcaptcha-response': hCaptchaResponse.value, + 'g-recaptcha-response': reCaptchaResponse.value, + token: user.value?.twoFactorEnabled ? token.value : undefined, }).then(res => { emit('login', res); onLogin(res); @@ -218,8 +218,8 @@ function loginFailed(err: any): void { } } - totpLogin = false; - signing = false; + totpLogin.value = false; + signing.value = false; } function resetPassword(): void { diff --git a/packages/frontend/src/components/MkSigninDialog.vue b/packages/frontend/src/components/MkSigninDialog.vue index 05cef6ed3b..6f961cff05 100644 --- a/packages/frontend/src/components/MkSigninDialog.vue +++ b/packages/frontend/src/components/MkSigninDialog.vue @@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; +import { shallowRef } from 'vue'; import MkSignin from '@/components/MkSignin.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; @@ -39,15 +39,15 @@ const emit = defineEmits<{ (ev: 'cancelled'): void; }>(); -const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); function onClose() { emit('cancelled'); - if (dialog) dialog.close(); + if (dialog.value) dialog.value.close(); } function onLogin(res) { emit('done', res); - if (dialog) dialog.close(); + if (dialog.value) dialog.value.close(); } </script> diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue index 389acb82bc..b46dc4bd93 100644 --- a/packages/frontend/src/components/MkSignupDialog.form.vue +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -80,11 +80,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; +import { ref, computed } from 'vue'; import { toUnicode } from 'punycode/'; import MkButton from './MkButton.vue'; import MkInput from './MkInput.vue'; -import MkSwitch from './MkSwitch.vue'; import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue'; import * as config from '@/config.js'; import * as os from '@/os.js'; @@ -106,35 +105,35 @@ const emit = defineEmits<{ const host = toUnicode(config.host); -let hcaptcha = $ref<Captcha | undefined>(); -let recaptcha = $ref<Captcha | undefined>(); -let turnstile = $ref<Captcha | undefined>(); +const hcaptcha = ref<Captcha | undefined>(); +const recaptcha = ref<Captcha | undefined>(); +const turnstile = ref<Captcha | undefined>(); -let username: string = $ref(''); -let password: string = $ref(''); -let retypedPassword: string = $ref(''); -let invitationCode: string = $ref(''); -let reason: string = $ref(''); -let email = $ref(''); -let usernameState: null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range' = $ref(null); -let emailState: null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error' = $ref(null); -let passwordStrength: '' | 'low' | 'medium' | 'high' = $ref(''); -let passwordRetypeState: null | 'match' | 'not-match' = $ref(null); -let submitting: boolean = $ref(false); -let hCaptchaResponse = $ref(null); -let reCaptchaResponse = $ref(null); -let turnstileResponse = $ref(null); -let usernameAbortController: null | AbortController = $ref(null); -let emailAbortController: null | AbortController = $ref(null); +const username = ref<string>(''); +const password = ref<string>(''); +const retypedPassword = ref<string>(''); +const invitationCode = ref<string>(''); +const reason = ref<string>(''); +const email = ref(''); +const usernameState = ref<null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range'>(null); +const emailState = ref<null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error'>(null); +const passwordStrength = ref<'' | 'low' | 'medium' | 'high'>(''); +const passwordRetypeState = ref<null | 'match' | 'not-match'>(null); +const submitting = ref<boolean>(false); +const hCaptchaResponse = ref(null); +const reCaptchaResponse = ref(null); +const turnstileResponse = ref(null); +const usernameAbortController = ref<null | AbortController>(null); +const emailAbortController = ref<null | AbortController>(null); -const shouldDisableSubmitting = $computed((): boolean => { - return submitting || - instance.enableHcaptcha && !hCaptchaResponse || - instance.enableRecaptcha && !reCaptchaResponse || - instance.enableTurnstile && !turnstileResponse || - instance.emailRequiredForSignup && emailState !== 'ok' || - usernameState !== 'ok' || - passwordRetypeState !== 'match'; +const shouldDisableSubmitting = computed((): boolean => { + return submitting.value || + instance.enableHcaptcha && !hCaptchaResponse.value || + instance.enableRecaptcha && !reCaptchaResponse.value || + instance.enableTurnstile && !turnstileResponse.value || + instance.emailRequiredForSignup && emailState.value !== 'ok' || + usernameState.value !== 'ok' || + passwordRetypeState.value !== 'match'; }); function getPasswordStrength(source: string): number { @@ -162,57 +161,57 @@ function getPasswordStrength(source: string): number { } function onChangeUsername(): void { - if (username === '') { - usernameState = null; + if (username.value === '') { + usernameState.value = null; return; } { const err = - !username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' : - username.length < 1 ? 'min-range' : - username.length > 20 ? 'max-range' : + !username.value.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' : + username.value.length < 1 ? 'min-range' : + username.value.length > 20 ? 'max-range' : null; if (err) { - usernameState = err; + usernameState.value = err; return; } } - if (usernameAbortController != null) { - usernameAbortController.abort(); + if (usernameAbortController.value != null) { + usernameAbortController.value.abort(); } - usernameState = 'wait'; - usernameAbortController = new AbortController(); + usernameState.value = 'wait'; + usernameAbortController.value = new AbortController(); os.api('username/available', { - username, - }, undefined, usernameAbortController.signal).then(result => { - usernameState = result.available ? 'ok' : 'unavailable'; + username: username.value, + }, undefined, usernameAbortController.value.signal).then(result => { + usernameState.value = result.available ? 'ok' : 'unavailable'; }).catch((err) => { if (err.name !== 'AbortError') { - usernameState = 'error'; + usernameState.value = 'error'; } }); } function onChangeEmail(): void { - if (email === '') { - emailState = null; + if (email.value === '') { + emailState.value = null; return; } - if (emailAbortController != null) { - emailAbortController.abort(); + if (emailAbortController.value != null) { + emailAbortController.value.abort(); } - emailState = 'wait'; - emailAbortController = new AbortController(); + emailState.value = 'wait'; + emailAbortController.value = new AbortController(); os.api('email-address/available', { - emailAddress: email, - }, undefined, emailAbortController.signal).then(result => { - emailState = result.available ? 'ok' : + emailAddress: email.value, + }, undefined, emailAbortController.value.signal).then(result => { + emailState.value = result.available ? 'ok' : result.reason === 'used' ? 'unavailable:used' : result.reason === 'format' ? 'unavailable:format' : result.reason === 'disposable' ? 'unavailable:disposable' : @@ -221,50 +220,49 @@ function onChangeEmail(): void { 'unavailable'; }).catch((err) => { if (err.name !== 'AbortError') { - emailState = 'error'; + emailState.value = 'error'; } }); } function onChangePassword(): void { - if (password === '') { - passwordStrength = ''; + if (password.value === '') { + passwordStrength.value = ''; return; } - const strength = getPasswordStrength(password); - passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; + const strength = getPasswordStrength(password.value); + passwordStrength.value = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; } function onChangePasswordRetype(): void { - if (retypedPassword === '') { - passwordRetypeState = null; + if (retypedPassword.value === '') { + passwordRetypeState.value = null; return; } - passwordRetypeState = password === retypedPassword ? 'match' : 'not-match'; + passwordRetypeState.value = password.value === retypedPassword.value ? 'match' : 'not-match'; } async function onSubmit(): Promise<void> { - if (submitting) return; - submitting = true; + if (submitting.value) return; + submitting.value = true; try { await os.api('signup', { - username, - password, - emailAddress: email, - invitationCode, - reason, - 'hcaptcha-response': hCaptchaResponse, - 'g-recaptcha-response': reCaptchaResponse, - 'turnstile-response': turnstileResponse, + username: username.value, + password: password.value, + emailAddress: email.value, + invitationCode: invitationCode.value, + reason: reason.value, + 'hcaptcha-response': hCaptchaResponse.value, + 'g-recaptcha-response': reCaptchaResponse.value, }); if (instance.emailRequiredForSignup) { os.alert({ type: 'success', title: i18n.ts._signup.almostThere, - text: i18n.t('_signup.emailSent', { email }), + text: i18n.t('_signup.emailSent', { email: email.value }), }); emit('signupEmailPending'); } else if (instance.approvalRequiredForSignup) { @@ -276,8 +274,8 @@ async function onSubmit(): Promise<void> { emit('approvalPending'); } else { const res = await os.api('signin', { - username, - password, + username: username.value, + password: password.value, }); emit('signup', res); @@ -286,10 +284,10 @@ async function onSubmit(): Promise<void> { } } } catch { - submitting = false; - hcaptcha?.reset?.(); - recaptcha?.reset?.(); - turnstile?.reset?.(); + submitting.value = false; + hcaptcha.value?.reset?.(); + recaptcha.value?.reset?.(); + turnstile.value?.reset?.(); os.alert({ type: 'error', diff --git a/packages/frontend/src/components/MkSignupDialog.rules.vue b/packages/frontend/src/components/MkSignupDialog.rules.vue index 09eac0732a..bc4fec305b 100644 --- a/packages/frontend/src/components/MkSignupDialog.rules.vue +++ b/packages/frontend/src/components/MkSignupDialog.rules.vue @@ -62,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onMounted, ref, watch } from 'vue'; +import { computed, ref } from 'vue'; import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; @@ -96,7 +96,7 @@ const tosPrivacyPolicyLabel = computed(() => { } else if (availablePrivacyPolicy) { return i18n.ts.privacyPolicy; } else { - return ""; + return ''; } }); diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue index 73d3b644e9..c8020c6636 100644 --- a/packages/frontend/src/components/MkSignupDialog.vue +++ b/packages/frontend/src/components/MkSignupDialog.vue @@ -33,13 +33,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; -import { $ref } from 'vue/macros'; +import { shallowRef, ref } from 'vue'; + import XSignup from '@/components/MkSignupDialog.form.vue'; import XServerRules from '@/components/MkSignupDialog.rules.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n.js'; -import { instance } from '@/instance.js'; const props = withDefaults(defineProps<{ autoSet?: boolean; @@ -52,17 +51,17 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); +const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); -const isAcceptedServerRule = $ref(false); +const isAcceptedServerRule = ref(false); function onSignup(res) { emit('done', res); - dialog.close(); + dialog.value.close(); } function onSignupEmailPending() { - dialog.close(); + dialog.value.close(); } function onApprovalPending() { diff --git a/packages/frontend/src/components/MkSubNoteContent.vue b/packages/frontend/src/components/MkSubNoteContent.vue index a91f1f444c..c071fb938a 100644 --- a/packages/frontend/src/components/MkSubNoteContent.vue +++ b/packages/frontend/src/components/MkSubNoteContent.vue @@ -8,10 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="{ [$style.clickToOpen]: defaultStore.state.clickToOpen }" @click="defaultStore.state.clickToOpen ? noteclick(note.id) : undefined"> <span v-if="note.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span> - <MkA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`" v-on:click.stop><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> + <MkA v-if="note.replyId" :class="$style.reply" :to="`/notes/${note.replyId}`" @click.stop><i class="ph-arrow-bend-left-up ph-bold ph-lg"></i></MkA> <Mfm v-if="note.text" :text="note.text" :author="note.user" :nyaize="'respect'" :isAnim="allowAnim" :emojiUrls="note.emojis"/> - <MkButton v-if="!allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> - <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> + <MkButton v-if="!allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> + <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated && !hideFiles" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> <div v-if="note.text && translating || note.text && translation" :class="$style.translation"> <MkLoading v-if="translating" mini/> <div v-else> @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only <Mfm :text="translation.text" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> </div> </div> - <MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`" v-on:click.stop>RN: ...</MkA> + <MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`" @click.stop>RN: ...</MkA> </div> <details v-if="note.files.length > 0" :open="!defaultStore.state.collapseFiles && !hideFiles"> <summary>({{ i18n.t('withNFiles', { n: note.files.length }) }})</summary> @@ -39,14 +39,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; +import { ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import * as mfm from '@sharkey/sfm-js'; import MkMediaList from '@/components/MkMediaList.vue'; import MkPoll from '@/components/MkPoll.vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; import { shouldCollapsed } from '@/scripts/collapsed.js'; import { defaultStore } from '@/store.js'; import { useRouter } from '@/router.js'; @@ -69,25 +68,25 @@ function noteclick(id: string) { } } -const parsed = $computed(() => props.note.text ? mfm.parse(props.note.text) : null); -const animated = $computed(() => parsed ? checkAnimationFromMfm(parsed) : null); -let allowAnim = $ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); +const parsed = computed(() => props.note.text ? mfm.parse(props.note.text) : null); +const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); +let allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); const isLong = defaultStore.state.expandLongNote && !props.hideFiles ? false : shouldCollapsed(props.note, []); function animatedMFM() { - if (allowAnim) { - allowAnim = false; + if (allowAnim.value) { + allowAnim.value = false; } else { os.confirm({ type: 'warning', text: i18n.ts._animatedMFM._alert.text, okText: i18n.ts._animatedMFM._alert.confirm, - }).then((res) => { if (!res.canceled) allowAnim = true; }); + }).then((res) => { if (!res.canceled) allowAnim.value = true; }); } } -const collapsed = $ref(isLong); +const collapsed = ref(isLong); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index 7521bd6c76..35e5aebbdd 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<div :class="[$style.root, { [$style.disabled]: disabled, [$style.checked]: checked }]"> +<div :class="[$style.root, { [$style.disabled]: disabled }]"> <input ref="input" type="checkbox" @@ -64,9 +64,6 @@ const toggle = () => { opacity: 0.6; cursor: not-allowed; } - - //&.checked { - //} } .input { diff --git a/packages/frontend/src/components/MkTagCloud.vue b/packages/frontend/src/components/MkTagCloud.vue index a3d82fee5e..083c34906f 100644 --- a/packages/frontend/src/components/MkTagCloud.vue +++ b/packages/frontend/src/components/MkTagCloud.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, watch, onBeforeUnmount } from 'vue'; +import { onMounted, watch, onBeforeUnmount, ref, shallowRef } from 'vue'; import tinycolor from 'tinycolor2'; const loaded = !!window.TagCanvas; @@ -23,13 +23,13 @@ const SAFE_FOR_HTML_ID = 'abcdefghijklmnopqrstuvwxyz'; const computedStyle = getComputedStyle(document.documentElement); const idForCanvas = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join(''); const idForTags = Array.from({ length: 16 }, () => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join(''); -let available = $ref(false); -let rootEl = $shallowRef<HTMLElement | null>(null); -let canvasEl = $shallowRef<HTMLCanvasElement | null>(null); -let tagsEl = $shallowRef<HTMLElement | null>(null); -let width = $ref(300); +const available = ref(false); +const rootEl = shallowRef<HTMLElement | null>(null); +const canvasEl = shallowRef<HTMLCanvasElement | null>(null); +const tagsEl = shallowRef<HTMLElement | null>(null); +const width = ref(300); -watch($$(available), () => { +watch(available, () => { try { window.TagCanvas.Start(idForCanvas, idForTags, { textColour: '#ffffff', @@ -52,15 +52,15 @@ watch($$(available), () => { }); onMounted(() => { - width = rootEl.offsetWidth; + width.value = rootEl.value.offsetWidth; if (loaded) { - available = true; + available.value = true; } else { document.head.appendChild(Object.assign(document.createElement('script'), { async: true, src: '/client-assets/tagcanvas.min.js', - })).addEventListener('load', () => available = true); + })).addEventListener('load', () => available.value = true); } }); diff --git a/packages/frontend/src/components/MkTextarea.vue b/packages/frontend/src/components/MkTextarea.vue index c35274959e..5c70adde11 100644 --- a/packages/frontend/src/components/MkTextarea.vue +++ b/packages/frontend/src/components/MkTextarea.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only :readonly="readonly" :placeholder="placeholder" :pattern="pattern" - :autocomplete="autocomplete" + :autocomplete="props.autocomplete" :spellcheck="spellcheck" @focus="focused = true" @blur="focused = false" @@ -26,16 +26,21 @@ SPDX-License-Identifier: AGPL-3.0-only ></textarea> </div> <div :class="$style.caption"><slot name="caption"></slot></div> + <button v-if="mfmPreview" style="font-size: 0.85em;" class="_textButton" type="button" @click="preview = !preview">{{ i18n.ts.preview }}</button> + <div v-if="mfmPreview" v-show="preview" v-panel :class="$style.mfmPreview"> + <Mfm :text="v"/> + </div> <MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ph-floppy-disk ph-bold ph-lg"></i> {{ i18n.ts.save }}</MkButton> </div> </template> <script lang="ts" setup> -import { onMounted, nextTick, ref, watch, computed, toRefs, shallowRef } from 'vue'; +import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, shallowRef } from 'vue'; import { debounce } from 'throttle-debounce'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; +import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js'; const props = defineProps<{ modelValue: string | null; @@ -46,6 +51,8 @@ const props = defineProps<{ placeholder?: string; autofocus?: boolean; autocomplete?: string; + mfmAutocomplete?: boolean | SuggestionType[], + mfmPreview?: boolean; spellcheck?: boolean; debounce?: boolean; manualSave?: boolean; @@ -68,6 +75,8 @@ const changed = ref(false); const invalid = ref(false); const filled = computed(() => v.value !== '' && v.value != null); const inputEl = shallowRef<HTMLTextAreaElement>(); +const preview = ref(false); +let autocomplete: Autocomplete; const focus = () => inputEl.value.focus(); const onInput = (ev) => { @@ -82,6 +91,16 @@ const onKeydown = (ev: KeyboardEvent) => { if (ev.code === 'Enter') { emit('enter'); } + + if (props.code && ev.key === 'Tab') { + const pos = inputEl.value?.selectionStart ?? 0; + const posEnd = inputEl.value?.selectionEnd ?? v.value.length; + v.value = v.value.slice(0, pos) + '\t' + v.value.slice(posEnd); + nextTick(() => { + inputEl.value?.setSelectionRange(pos + 1, pos + 1); + }); + ev.preventDefault(); + } }; const updated = () => { @@ -113,6 +132,16 @@ onMounted(() => { focus(); } }); + + if (props.mfmAutocomplete) { + autocomplete = new Autocomplete(inputEl.value, v, props.mfmAutocomplete === true ? null : props.mfmAutocomplete); + } +}); + +onUnmounted(() => { + if (autocomplete) { + autocomplete.detach(); + } }); </script> @@ -194,4 +223,12 @@ onMounted(() => { .save { margin: 8px 0 0 0; } + +.mfmPreview { + padding: 12px; + border-radius: var(--radius); + box-sizing: border-box; + min-height: 130px; + pointer-events: none; +} </style> diff --git a/packages/frontend/src/components/MkTimeline.vue b/packages/frontend/src/components/MkTimeline.vue index 85096dc583..8bd68c0fd2 100644 --- a/packages/frontend/src/components/MkTimeline.vue +++ b/packages/frontend/src/components/MkTimeline.vue @@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, watch, onUnmounted, provide } from 'vue'; +import { computed, watch, onUnmounted, provide, ref } from 'vue'; import { Connection } from 'misskey-js/built/streaming.js'; import MkNotes from '@/components/MkNotes.vue'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; @@ -65,8 +65,8 @@ type TimelineQueryType = { roleId?: string } -const prComponent: InstanceType<typeof MkPullToRefresh> = $ref(); -const tlComponent: InstanceType<typeof MkNotes> = $ref(); +const prComponent = ref<InstanceType<typeof MkPullToRefresh>>(); +const tlComponent = ref<InstanceType<typeof MkNotes>>(); let tlNotesCount = 0; @@ -77,7 +77,7 @@ const prepend = note => { note._shouldInsertAd_ = true; } - tlComponent.pagingComponent?.prepend(note); + tlComponent.value.pagingComponent?.prepend(note); emit('note'); @@ -271,7 +271,7 @@ function reloadTimeline() { return new Promise<void>((res) => { tlNotesCount = 0; - tlComponent.pagingComponent?.reload().then(() => { + tlComponent.value.pagingComponent?.reload().then(() => { res(); }); }); diff --git a/packages/frontend/src/components/MkToast.vue b/packages/frontend/src/components/MkToast.vue index 3b26b50a0b..82cd236193 100644 --- a/packages/frontend/src/components/MkToast.vue +++ b/packages/frontend/src/components/MkToast.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted } from 'vue'; +import { onMounted, ref } from 'vue'; import * as os from '@/os.js'; import { defaultStore } from '@/store.js'; @@ -35,11 +35,11 @@ const emit = defineEmits<{ }>(); const zIndex = os.claimZIndex('high'); -let showing = $ref(true); +const showing = ref(true); onMounted(() => { window.setTimeout(() => { - showing = false; + showing.value = false; }, 4000); }); </script> diff --git a/packages/frontend/src/components/MkTokenGenerateWindow.vue b/packages/frontend/src/components/MkTokenGenerateWindow.vue index 8958accc4a..f5fa86a908 100644 --- a/packages/frontend/src/components/MkTokenGenerateWindow.vue +++ b/packages/frontend/src/components/MkTokenGenerateWindow.vue @@ -41,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; +import { shallowRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkInput from './MkInput.vue'; import MkSwitch from './MkSwitch.vue'; @@ -67,37 +67,37 @@ const emit = defineEmits<{ (ev: 'done', result: { name: string | null, permissions: string[] }): void; }>(); -const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); -let name = $ref(props.initialName); -let permissions = $ref({}); +const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); +const name = ref(props.initialName); +const permissions = ref({}); if (props.initialPermissions) { for (const kind of props.initialPermissions) { - permissions[kind] = true; + permissions.value[kind] = true; } } else { for (const kind of Misskey.permissions) { - permissions[kind] = false; + permissions.value[kind] = false; } } function ok(): void { emit('done', { - name: name, - permissions: Object.keys(permissions).filter(p => permissions[p]), + name: name.value, + permissions: Object.keys(permissions.value).filter(p => permissions.value[p]), }); - dialog.close(); + dialog.value.close(); } function disableAll(): void { - for (const p in permissions) { - permissions[p] = false; + for (const p in permissions.value) { + permissions.value[p] = false; } } function enableAll(): void { - for (const p in permissions) { - permissions[p] = true; + for (const p in permissions.value) { + permissions.value[p] = true; } } </script> diff --git a/packages/frontend/src/components/MkTutorialDialog.Timeline.vue b/packages/frontend/src/components/MkTutorialDialog.Timeline.vue index 421c0a8af8..c2384423fd 100644 --- a/packages/frontend/src/components/MkTutorialDialog.Timeline.vue +++ b/packages/frontend/src/components/MkTutorialDialog.Timeline.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.divider"></div> <I18n :src="i18n.ts._initialTutorial._timeline.description3" tag="div" style="padding: 0 16px;"> <template #link> - <a href="https://misskey-hub.net/docs/features/timeline.html" target="_blank" class="_link">{{ i18n.ts.help }}</a> + <a href="https://misskey-hub.net/docs/for-users/features/timeline/" target="_blank" class="_link">{{ i18n.ts.help }}</a> </template> </I18n> diff --git a/packages/frontend/src/components/MkTutorialDialog.vue b/packages/frontend/src/components/MkTutorialDialog.vue index 5db2cc100a..a734f93ec9 100644 --- a/packages/frontend/src/components/MkTutorialDialog.vue +++ b/packages/frontend/src/components/MkTutorialDialog.vue @@ -130,7 +130,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div style="font-size: 120%;">{{ i18n.ts._initialTutorial._done.title }}</div> <I18n :src="i18n.ts._initialTutorial._done.description" tag="div" style="padding: 0 16px;"> <template #link> - <a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a> + <a href="https://misskey-hub.net/docs/for-users/" target="_blank" class="_link">{{ i18n.ts.help }}</a> </template> </I18n> <div>{{ i18n.t('_initialAccountSetting.haveFun', { name: instance.name ?? host }) }}</div> diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index 78c62e1250..486aaa0bbd 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only <iframe ref="tweet" allow="fullscreen;web-share" - sandbox="allow-popups allow-scripts allow-same-origin" + sandbox="allow-popups allow-popups-to-escape-sandbox allow-scripts allow-same-origin" scrolling="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px`, border: 0 }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${defaultStore.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`" @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <div v-else> <component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substring(local.length) : url" rel="nofollow noopener" :target="target" :title="url"> - <div v-if="thumbnail" :class="$style.thumbnail" :style="defaultStore.state.enableDataSaverMode ? '' : `background-image: url('${thumbnail}')`"> + <div v-if="thumbnail && !sensitive" :class="$style.thumbnail" :style="defaultStore.state.dataSaver.urlPreview ? '' : `background-image: url('${thumbnail}')`"> </div> <article :class="$style.body"> <header :class="$style.header"> @@ -83,7 +83,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, onUnmounted } from 'vue'; +import { defineAsyncComponent, onUnmounted, ref } from 'vue'; import type { summaly } from 'summaly'; import { url as local } from '@/config.js'; import { i18n } from '@/i18n.js'; @@ -107,35 +107,36 @@ const props = withDefaults(defineProps<{ }); const MOBILE_THRESHOLD = 500; -const isMobile = $ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD); +const isMobile = ref(deviceKind === 'smartphone' || window.innerWidth <= MOBILE_THRESHOLD); const self = props.url.startsWith(local); const attr = self ? 'to' : 'href'; const target = self ? null : '_blank'; -let fetching = $ref(true); -let title = $ref<string | null>(null); -let description = $ref<string | null>(null); -let thumbnail = $ref<string | null>(null); -let icon = $ref<string | null>(null); -let sitename = $ref<string | null>(null); -let player = $ref({ +const fetching = ref(true); +const title = ref<string | null>(null); +const description = ref<string | null>(null); +const thumbnail = ref<string | null>(null); +const icon = ref<string | null>(null); +const sitename = ref<string | null>(null); +const sensitive = ref<boolean>(false); +const player = ref({ url: null, width: null, height: null, } as SummalyResult['player']); -let playerEnabled = $ref(false); -let tweetId = $ref<string | null>(null); -let tweetExpanded = $ref(props.detail); +const playerEnabled = ref(false); +const tweetId = ref<string | null>(null); +const tweetExpanded = ref(props.detail); const embedId = `embed${Math.random().toString().replace(/\D/, '')}`; -let tweetHeight = $ref(150); -let unknownUrl = $ref(false); +const tweetHeight = ref(150); +const unknownUrl = ref(false); const requestUrl = new URL(props.url); if (!['http:', 'https:'].includes(requestUrl.protocol)) throw new Error('invalid url'); if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twitter.com' || requestUrl.hostname === 'x.com' || requestUrl.hostname === 'mobile.x.com') { const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/); - if (m) tweetId = m[1]; + if (m) tweetId.value = m[1]; } if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/(?:watch|channel)')) { @@ -147,8 +148,8 @@ requestUrl.hash = ''; window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`) .then(res => { if (!res.ok) { - fetching = false; - unknownUrl = true; + fetching.value = false; + unknownUrl.value = true; return; } @@ -156,20 +157,21 @@ window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLa }) .then((info: SummalyResult) => { if (info.url == null) { - fetching = false; - unknownUrl = true; + fetching.value = false; + unknownUrl.value = true; return; } - fetching = false; - unknownUrl = false; + fetching.value = false; + unknownUrl.value = false; - title = info.title; - description = info.description; - thumbnail = info.thumbnail; - icon = info.icon; - sitename = info.sitename; - player = info.player; + title.value = info.title; + description.value = info.description; + thumbnail.value = info.thumbnail; + icon.value = info.icon; + sitename.value = info.sitename; + player.value = info.player; + sensitive.value = info.sensitive ?? false; }); function adjustTweetHeight(message: any) { @@ -178,7 +180,7 @@ function adjustTweetHeight(message: any) { if (embed?.method !== 'twttr.private.resize') return; if (embed?.id !== embedId) return; const height = embed?.params[0]?.height; - if (height) tweetHeight = height; + if (height) tweetHeight.value = height; } const openPlayer = (): void => { diff --git a/packages/frontend/src/components/MkUrlPreviewPopup.vue b/packages/frontend/src/components/MkUrlPreviewPopup.vue index 0ab012dfb7..81c383540c 100644 --- a/packages/frontend/src/components/MkUrlPreviewPopup.vue +++ b/packages/frontend/src/components/MkUrlPreviewPopup.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted } from 'vue'; +import { onMounted, ref } from 'vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue'; import * as os from '@/os.js'; import { defaultStore } from '@/store.js'; @@ -28,16 +28,16 @@ const emit = defineEmits<{ }>(); const zIndex = os.claimZIndex('middle'); -let top = $ref(0); -let left = $ref(0); +const top = ref(0); +const left = ref(0); onMounted(() => { const rect = props.source.getBoundingClientRect(); const x = Math.max((rect.left + (props.source.offsetWidth / 2)) - (300 / 2), 6) + window.pageXOffset; const y = rect.top + props.source.offsetHeight + window.pageYOffset; - top = y; - left = x; + top.value = y; + left.value = x; }); </script> diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue index 42ccb621b6..e1237659c2 100644 --- a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; +import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkModalWindow from '@/components/MkModalWindow.vue'; import MkButton from '@/components/MkButton.vue'; @@ -66,12 +66,12 @@ const props = defineProps<{ announcement?: any, }>(); -let dialog = $ref(null); -let title: string = $ref(props.announcement ? props.announcement.title : ''); -let text: string = $ref(props.announcement ? props.announcement.text : ''); -let icon: string = $ref(props.announcement ? props.announcement.icon : 'info'); -let display: string = $ref(props.announcement ? props.announcement.display : 'dialog'); -let needConfirmationToRead = $ref(props.announcement ? props.announcement.needConfirmationToRead : false); +const dialog = ref(null); +const title = ref<string>(props.announcement ? props.announcement.title : ''); +const text = ref<string>(props.announcement ? props.announcement.text : ''); +const icon = ref<string>(props.announcement ? props.announcement.icon : 'info'); +const display = ref<string>(props.announcement ? props.announcement.display : 'dialog'); +const needConfirmationToRead = ref(props.announcement ? props.announcement.needConfirmationToRead : false); const emit = defineEmits<{ (ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void, @@ -80,12 +80,12 @@ const emit = defineEmits<{ async function done() { const params = { - title: title, - text: text, - icon: icon, + title: title.value, + text: text.value, + icon: icon.value, imageUrl: null, - display: display, - needConfirmationToRead: needConfirmationToRead, + display: display.value, + needConfirmationToRead: needConfirmationToRead.value, userId: props.user.id, }; @@ -102,7 +102,7 @@ async function done() { }, }); - dialog.close(); + dialog.value.close(); } else { const created = await os.apiWithDialog('admin/announcements/create', params); @@ -110,14 +110,14 @@ async function done() { created: created, }); - dialog.close(); + dialog.value.close(); } } async function del() { const { canceled } = await os.confirm({ type: 'warning', - text: i18n.t('removeAreYouSure', { x: title }), + text: i18n.t('removeAreYouSure', { x: title.value }), }); if (canceled) return; @@ -127,7 +127,7 @@ async function del() { emit('done', { deleted: true, }); - dialog.close(); + dialog.value.close(); }); } </script> diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue index 978c5005c8..b9c7377972 100644 --- a/packages/frontend/src/components/MkUserCardMini.vue +++ b/packages/frontend/src/components/MkUserCardMini.vue @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import * as Misskey from 'misskey-js'; -import { onMounted } from 'vue'; +import { onMounted, ref } from 'vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; import * as os from '@/os.js'; import { acct } from '@/filters/user.js'; @@ -28,14 +28,14 @@ const props = withDefaults(defineProps<{ withChart: true, }); -let chartValues = $ref<number[] | null>(null); +const chartValues = ref<number[] | null>(null); onMounted(() => { if (props.withChart) { os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16 + 1, span: 'day' }).then(res => { // 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く res.inc.splice(0, 1); - chartValues = res.inc; + chartValues.value = res.inc; }); } }); diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue index 322ffee38e..4e326911d8 100644 --- a/packages/frontend/src/components/MkUserInfo.vue +++ b/packages/frontend/src/components/MkUserInfo.vue @@ -22,10 +22,10 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.statusItem"> <p :class="$style.statusItemLabel">{{ i18n.ts.notes }}</p><span :class="$style.statusItemValue">{{ number(user.notesCount) }}</span> </div> - <div v-if="isFfVisibleForMe(user)" :class="$style.statusItem"> + <div v-if="isFollowingVisibleForMe(user)" :class="$style.statusItem"> <p :class="$style.statusItemLabel">{{ i18n.ts.following }}</p><span :class="$style.statusItemValue">{{ number(user.followingCount) }}</span> </div> - <div v-if="isFfVisibleForMe(user)" :class="$style.statusItem"> + <div v-if="isFollowersVisibleForMe(user)" :class="$style.statusItem"> <p :class="$style.statusItemLabel">{{ i18n.ts.followers }}</p><span :class="$style.statusItemValue">{{ number(user.followersCount) }}</span> </div> </div> @@ -40,7 +40,7 @@ import number from '@/filters/number.js'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; -import { isFfVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; +import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; defineProps<{ user: Misskey.entities.UserDetailed; diff --git a/packages/frontend/src/components/MkUserOnlineIndicator.vue b/packages/frontend/src/components/MkUserOnlineIndicator.vue index c6e1218c0f..76470cba88 100644 --- a/packages/frontend/src/components/MkUserOnlineIndicator.vue +++ b/packages/frontend/src/components/MkUserOnlineIndicator.vue @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; +import { computed } from 'vue'; import * as Misskey from 'misskey-js'; import { i18n } from '@/i18n.js'; @@ -24,7 +24,7 @@ const props = defineProps<{ user: Misskey.entities.User; }>(); -const text = $computed(() => { +const text = computed(() => { switch (props.user.onlineStatus) { case 'online': return i18n.ts.online; case 'active': return i18n.ts.active; diff --git a/packages/frontend/src/components/MkUserPopup.vue b/packages/frontend/src/components/MkUserPopup.vue index d958b325e5..ec2c48b1cf 100644 --- a/packages/frontend/src/components/MkUserPopup.vue +++ b/packages/frontend/src/components/MkUserPopup.vue @@ -47,11 +47,11 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.statusItemLabel">{{ i18n.ts.notes }}</div> <div>{{ number(user.notesCount) }}</div> </div> - <div v-if="isFfVisibleForMe(user)" :class="$style.statusItem"> + <div v-if="isFollowingVisibleForMe(user)" :class="$style.statusItem"> <div :class="$style.statusItemLabel">{{ i18n.ts.following }}</div> <div>{{ number(user.followingCount) }}</div> </div> - <div v-if="isFfVisibleForMe(user)" :class="$style.statusItem"> + <div v-if="isFollowersVisibleForMe(user)" :class="$style.statusItem"> <div :class="$style.statusItemLabel">{{ i18n.ts.followers }}</div> <div>{{ number(user.followersCount) }}</div> </div> @@ -67,7 +67,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted } from 'vue'; +import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkFollowButton from '@/components/MkFollowButton.vue'; import { userPage } from '@/filters/user.js'; @@ -77,7 +77,7 @@ import number from '@/filters/number.js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; import { $i } from '@/account.js'; -import { isFfVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; +import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/scripts/isFfVisibleForMe.js'; const props = defineProps<{ showing: boolean; @@ -92,18 +92,18 @@ const emit = defineEmits<{ }>(); const zIndex = os.claimZIndex('middle'); -let user = $ref<Misskey.entities.UserDetailed | null>(null); -let top = $ref(0); -let left = $ref(0); +const user = ref<Misskey.entities.UserDetailed | null>(null); +const top = ref(0); +const left = ref(0); function showMenu(ev: MouseEvent) { - const { menu, cleanup } = getUserMenu(user); + const { menu, cleanup } = getUserMenu(user.value); os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup); } onMounted(() => { if (typeof props.q === 'object') { - user = props.q; + user.value = props.q; } else { const query = props.q.startsWith('@') ? Misskey.acct.parse(props.q.substring(1)) : @@ -111,7 +111,7 @@ onMounted(() => { os.api('users/show', query).then(res => { if (!props.showing) return; - user = res; + user.value = res; }); } @@ -119,8 +119,8 @@ onMounted(() => { const x = ((rect.left + (props.source.offsetWidth / 2)) - (300 / 2)) + window.pageXOffset; const y = rect.top + props.source.offsetHeight + window.pageYOffset; - top = y; - left = x; + top.value = y; + left.value = x; }); </script> diff --git a/packages/frontend/src/components/MkUserSelectDialog.vue b/packages/frontend/src/components/MkUserSelectDialog.vue index ac38c4b62f..9d41147bd2 100644 --- a/packages/frontend/src/components/MkUserSelectDialog.vue +++ b/packages/frontend/src/components/MkUserSelectDialog.vue @@ -57,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted } from 'vue'; +import { onMounted, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkInput from '@/components/MkInput.vue'; import FormSplit from '@/components/form/split.vue'; @@ -78,43 +78,43 @@ const props = defineProps<{ includeSelf?: boolean; }>(); -let username = $ref(''); -let host = $ref(''); -let users: Misskey.entities.UserDetailed[] = $ref([]); -let recentUsers: Misskey.entities.UserDetailed[] = $ref([]); -let selected: Misskey.entities.UserDetailed | null = $ref(null); -let dialogEl = $ref(); +const username = ref(''); +const host = ref(''); +const users = ref<Misskey.entities.UserDetailed[]>([]); +const recentUsers = ref<Misskey.entities.UserDetailed[]>([]); +const selected = ref<Misskey.entities.UserDetailed | null>(null); +const dialogEl = ref(); const search = () => { - if (username === '' && host === '') { - users = []; + if (username.value === '' && host.value === '') { + users.value = []; return; } os.api('users/search-by-username-and-host', { - username: username, - host: host, + username: username.value, + host: host.value, limit: 10, detail: false, }).then(_users => { - users = _users; + users.value = _users; }); }; const ok = () => { - if (selected == null) return; - emit('ok', selected); - dialogEl.close(); + if (selected.value == null) return; + emit('ok', selected.value); + dialogEl.value.close(); // 最近使ったユーザー更新 let recents = defaultStore.state.recentlyUsedUsers; - recents = recents.filter(x => x !== selected.id); - recents.unshift(selected.id); + recents = recents.filter(x => x !== selected.value.id); + recents.unshift(selected.value.id); defaultStore.set('recentlyUsedUsers', recents.splice(0, 16)); }; const cancel = () => { emit('cancel'); - dialogEl.close(); + dialogEl.value.close(); }; onMounted(() => { @@ -122,9 +122,9 @@ onMounted(() => { userIds: defaultStore.state.recentlyUsedUsers, }).then(users => { if (props.includeSelf && users.find(x => $i ? x.id === $i.id : true) == null) { - recentUsers = [$i, ...users]; + recentUsers.value = [$i, ...users]; } else { - recentUsers = users; + recentUsers.value = users; } }); }); diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue index 4ecca7334c..5f3f5b81dd 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue @@ -34,15 +34,9 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, ref, watch } from 'vue'; -import { instance } from '@/instance.js'; import { i18n } from '@/i18n.js'; -import MkButton from '@/components/MkButton.vue'; import MkFolder from '@/components/MkFolder.vue'; import XUser from '@/components/MkUserSetupDialog.User.vue'; -import MkInfo from '@/components/MkInfo.vue'; -import * as os from '@/os.js'; -import { $i } from '@/account.js'; import MkPagination from '@/components/MkPagination.vue'; const pinnedUsers = { endpoint: 'pinned-users', noPaging: true }; diff --git a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue index 7401dbddb1..664c4da203 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Privacy.vue @@ -36,18 +36,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, ref, watch } from 'vue'; -import { instance } from '@/instance.js'; +import { ref, watch } from 'vue'; import { i18n } from '@/i18n.js'; import MkSwitch from '@/components/MkSwitch.vue'; import MkInfo from '@/components/MkInfo.vue'; import MkFolder from '@/components/MkFolder.vue'; import * as os from '@/os.js'; -import { $i } from '@/account.js'; -let isLocked = ref(false); -let hideOnlineStatus = ref(false); -let noCrawle = ref(false); +const isLocked = ref(false); +const hideOnlineStatus = ref(false); +const noCrawle = ref(false); watch([isLocked, hideOnlineStatus, noCrawle], () => { os.api('i/update', { diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue index 8de9bbdbb1..37aa677b44 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -30,8 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, ref, watch } from 'vue'; -import { instance } from '@/instance.js'; +import { ref, watch } from 'vue'; import { i18n } from '@/i18n.js'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.vue b/packages/frontend/src/components/MkUserSetupDialog.User.vue index 01a943b7a0..621995cc5b 100644 --- a/packages/frontend/src/components/MkUserSetupDialog.User.vue +++ b/packages/frontend/src/components/MkUserSetupDialog.User.vue @@ -29,7 +29,6 @@ import * as Misskey from 'misskey-js'; import { ref } from 'vue'; import MkButton from '@/components/MkButton.vue'; import { i18n } from '@/i18n.js'; -import { $i } from '@/account.js'; import * as os from '@/os.js'; const props = defineProps<{ diff --git a/packages/frontend/src/components/MkVisibilityPicker.vue b/packages/frontend/src/components/MkVisibilityPicker.vue index 325829a8a8..61edc345a9 100644 --- a/packages/frontend/src/components/MkVisibilityPicker.vue +++ b/packages/frontend/src/components/MkVisibilityPicker.vue @@ -42,12 +42,12 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { nextTick } from 'vue'; +import { nextTick, shallowRef, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkModal from '@/components/MkModal.vue'; import { i18n } from '@/i18n.js'; -const modal = $shallowRef<InstanceType<typeof MkModal>>(); +const modal = shallowRef<InstanceType<typeof MkModal>>(); const props = withDefaults(defineProps<{ currentVisibility: typeof Misskey.noteVisibilities[number]; @@ -62,13 +62,13 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -let v = $ref(props.currentVisibility); +const v = ref(props.currentVisibility); function choose(visibility: typeof Misskey.noteVisibilities[number]): void { - v = visibility; + v.value = visibility; emit('changeVisibility', visibility); nextTick(() => { - if (modal) modal.close(); + if (modal.value) modal.value.close(); }); } </script> diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue index 26de7dee52..746ed3e0de 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue @@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted } from 'vue'; +import { onMounted, shallowRef, ref } from 'vue'; import { Chart } from 'chart.js'; import gradient from 'chartjs-plugin-gradient'; import tinycolor from 'tinycolor2'; @@ -25,11 +25,11 @@ import { initChart } from '@/scripts/init-chart.js'; initChart(); -const chartEl = $shallowRef<HTMLCanvasElement>(null); +const chartEl = shallowRef<HTMLCanvasElement>(null); const now = new Date(); let chartInstance: Chart = null; const chartLimit = 30; -let fetching = $ref(true); +const fetching = ref(true); const { handler: externalTooltipHandler } = useChartTooltip(); @@ -65,7 +65,7 @@ async function renderChart() { const max = Math.max(...raw.read); - chartInstance = new Chart(chartEl, { + chartInstance = new Chart(chartEl.value, { type: 'bar', data: { datasets: [{ @@ -147,7 +147,7 @@ async function renderChart() { plugins: [chartVLine(vLineColor)], }); - fetching = false; + fetching.value = false; } onMounted(async () => { diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue index fe76ded7b4..862a38bd54 100644 --- a/packages/frontend/src/components/MkVisitorDashboard.vue +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -54,9 +54,8 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; +import { ref } from 'vue'; import * as Misskey from 'misskey-js'; -import XTimeline from './welcome.timeline.vue'; import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue'; import MkButton from '@/components/MkButton.vue'; @@ -66,20 +65,18 @@ import { instanceName } from '@/config.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { instance } from '@/instance.js'; -import number from '@/filters/number.js'; import MkNumber from '@/components/MkNumber.vue'; import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart.vue'; -let meta = $ref<Misskey.entities.Instance>(); -let stats = $ref(null); +const meta = ref<Misskey.entities.MetaResponse | null>(null); +const stats = ref<Misskey.entities.StatsResponse | null>(null); os.api('meta', { detail: true }).then(_meta => { - meta = _meta; + meta.value = _meta; }); -os.api('stats', { -}).then((res) => { - stats = res; +os.api('stats', {}).then((res) => { + stats.value = res; }); function signin() { @@ -107,35 +104,35 @@ function showMenu(ev) { action: () => { os.pageWindow('/about-sharkey'); }, - }, null, (instance.impressumUrl) ? { + }, { type: 'divider' }, (instance.impressumUrl) ? { text: i18n.ts.impressum, icon: 'ph-newspaper-clipping ph-bold ph-lg', action: () => { - window.open(instance.impressumUrl, '_blank'); + window.open(instance.impressumUrl, '_blank', 'noopener'); }, } : undefined, (instance.tosUrl) ? { text: i18n.ts.termsOfService, icon: 'ph-notebook ph-bold ph-lg', action: () => { - window.open(instance.tosUrl, '_blank'); + window.open(instance.tosUrl, '_blank', 'noopener'); }, } : undefined, (instance.privacyPolicyUrl) ? { text: i18n.ts.privacyPolicy, icon: 'ph-shield ph-bold ph-lg', action: () => { - window.open(instance.privacyPolicyUrl, '_blank'); + window.open(instance.privacyPolicyUrl, '_blank', 'noopener'); }, - } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : null, { + } : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : { type: 'divider' }, { text: i18n.ts.help, icon: 'ph-question ph-bold ph-lg', action: () => { - window.open('https://misskey-hub.net/help.md', '_blank'); + window.open('https://misskey-hub.net/docs/for-users/', '_blank', 'noopener'); }, }], ev.currentTarget ?? ev.target); } function exploreOtherServers() { - window.open('https://joinsharkey.org/#findaninstance', '_blank'); + window.open('https://joinsharkey.org/#findaninstance', '_blank', 'noopener'); } </script> diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue index d6c3e3f81d..e5b8bd9b15 100644 --- a/packages/frontend/src/components/MkWindow.vue +++ b/packages/frontend/src/components/MkWindow.vue @@ -53,10 +53,10 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onBeforeUnmount, onMounted, provide } from 'vue'; +import { onBeforeUnmount, onMounted, provide, shallowRef, ref } from 'vue'; import contains from '@/scripts/contains.js'; import * as os from '@/os.js'; -import { MenuItem } from '@/types/menu'; +import { MenuItem } from '@/types/menu.js'; import { i18n } from '@/i18n.js'; import { defaultStore } from '@/store.js'; @@ -107,18 +107,18 @@ const emit = defineEmits<{ provide('inWindow', true); -let rootEl = $shallowRef<HTMLElement | null>(); -let showing = $ref(true); +const rootEl = shallowRef<HTMLElement | null>(); +const showing = ref(true); let beforeClickedAt = 0; -let maximized = $ref(false); -let minimized = $ref(false); +const maximized = ref(false); +const minimized = ref(false); let unResizedTop = ''; let unResizedLeft = ''; let unResizedWidth = ''; let unResizedHeight = ''; function close() { - showing = false; + showing.value = false; } function onKeydown(evt) { @@ -137,46 +137,46 @@ function onContextmenu(ev: MouseEvent) { // 最前面へ移動 function top() { - if (rootEl) { - rootEl.style.zIndex = os.claimZIndex(props.front ? 'middle' : 'low'); + if (rootEl.value) { + rootEl.value.style.zIndex = os.claimZIndex(props.front ? 'middle' : 'low'); } } function maximize() { - maximized = true; - unResizedTop = rootEl.style.top; - unResizedLeft = rootEl.style.left; - unResizedWidth = rootEl.style.width; - unResizedHeight = rootEl.style.height; - rootEl.style.top = '0'; - rootEl.style.left = '0'; - rootEl.style.width = '100%'; - rootEl.style.height = '100%'; + maximized.value = true; + unResizedTop = rootEl.value.style.top; + unResizedLeft = rootEl.value.style.left; + unResizedWidth = rootEl.value.style.width; + unResizedHeight = rootEl.value.style.height; + rootEl.value.style.top = '0'; + rootEl.value.style.left = '0'; + rootEl.value.style.width = '100%'; + rootEl.value.style.height = '100%'; } function unMaximize() { - maximized = false; - rootEl.style.top = unResizedTop; - rootEl.style.left = unResizedLeft; - rootEl.style.width = unResizedWidth; - rootEl.style.height = unResizedHeight; + maximized.value = false; + rootEl.value.style.top = unResizedTop; + rootEl.value.style.left = unResizedLeft; + rootEl.value.style.width = unResizedWidth; + rootEl.value.style.height = unResizedHeight; } function minimize() { - minimized = true; - unResizedWidth = rootEl.style.width; - unResizedHeight = rootEl.style.height; - rootEl.style.width = minWidth + 'px'; - rootEl.style.height = props.mini ? '32px' : '39px'; + minimized.value = true; + unResizedWidth = rootEl.value.style.width; + unResizedHeight = rootEl.value.style.height; + rootEl.value.style.width = minWidth + 'px'; + rootEl.value.style.height = props.mini ? '32px' : '39px'; } function unMinimize() { - const main = rootEl; + const main = rootEl.value; if (main == null) return; - minimized = false; - rootEl.style.width = unResizedWidth; - rootEl.style.height = unResizedHeight; + minimized.value = false; + rootEl.value.style.width = unResizedWidth; + rootEl.value.style.height = unResizedHeight; const browserWidth = window.innerWidth; const browserHeight = window.innerHeight; const windowWidth = main.offsetWidth; @@ -192,7 +192,7 @@ function onBodyMousedown() { } function onDblClick() { - if (minimized) { + if (minimized.value) { unMinimize(); } else { maximize(); @@ -205,7 +205,7 @@ function onHeaderMousedown(evt: MouseEvent) { let beforeMaximized = false; - if (maximized) { + if (maximized.value) { beforeMaximized = true; unMaximize(); } @@ -219,7 +219,7 @@ function onHeaderMousedown(evt: MouseEvent) { beforeClickedAt = Date.now(); - const main = rootEl; + const main = rootEl.value; if (main == null) return; if (!contains(main, document.activeElement)) main.focus(); @@ -251,8 +251,8 @@ function onHeaderMousedown(evt: MouseEvent) { // 右はみ出し if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth; - rootEl.style.left = moveLeft + 'px'; - rootEl.style.top = moveTop + 'px'; + rootEl.value.style.left = moveLeft + 'px'; + rootEl.value.style.top = moveTop + 'px'; } if (beforeMaximized) { @@ -270,7 +270,7 @@ function onHeaderMousedown(evt: MouseEvent) { // 上ハンドル掴み時 function onTopHandleMousedown(evt) { - const main = rootEl; + const main = rootEl.value; // どういうわけかnullになることがある if (main == null) return; @@ -298,7 +298,7 @@ function onTopHandleMousedown(evt) { // 右ハンドル掴み時 function onRightHandleMousedown(evt) { - const main = rootEl; + const main = rootEl.value; if (main == null) return; const base = evt.clientX; @@ -323,7 +323,7 @@ function onRightHandleMousedown(evt) { // 下ハンドル掴み時 function onBottomHandleMousedown(evt) { - const main = rootEl; + const main = rootEl.value; if (main == null) return; const base = evt.clientY; @@ -348,7 +348,7 @@ function onBottomHandleMousedown(evt) { // 左ハンドル掴み時 function onLeftHandleMousedown(evt) { - const main = rootEl; + const main = rootEl.value; if (main == null) return; const base = evt.clientX; @@ -400,27 +400,27 @@ function onBottomLeftHandleMousedown(evt) { // 高さを適用 function applyTransformHeight(height) { if (height > window.innerHeight) height = window.innerHeight; - rootEl.style.height = height + 'px'; + rootEl.value.style.height = height + 'px'; } // 幅を適用 function applyTransformWidth(width) { if (width > window.innerWidth) width = window.innerWidth; - rootEl.style.width = width + 'px'; + rootEl.value.style.width = width + 'px'; } // Y座標を適用 function applyTransformTop(top) { - rootEl.style.top = top + 'px'; + rootEl.value.style.top = top + 'px'; } // X座標を適用 function applyTransformLeft(left) { - rootEl.style.left = left + 'px'; + rootEl.value.style.left = left + 'px'; } function onBrowserResize() { - const main = rootEl; + const main = rootEl.value; if (main == null) return; const position = main.getBoundingClientRect(); @@ -438,8 +438,8 @@ onMounted(() => { applyTransformWidth(props.initialWidth); if (props.initialHeight) applyTransformHeight(props.initialHeight); - applyTransformTop((window.innerHeight / 2) - (rootEl.offsetHeight / 2)); - applyTransformLeft((window.innerWidth / 2) - (rootEl.offsetWidth / 2)); + applyTransformTop((window.innerHeight / 2) - (rootEl.value.offsetHeight / 2)); + applyTransformLeft((window.innerWidth / 2) - (rootEl.value.offsetWidth / 2)); // 他のウィンドウ内のボタンなどを押してこのウィンドウが開かれた場合、親が最前面になろうとするのでそれに隠されないようにする top(); diff --git a/packages/frontend/src/components/MkYouTubePlayer.vue b/packages/frontend/src/components/MkYouTubePlayer.vue index 7460515c33..a9b2e8a00d 100644 --- a/packages/frontend/src/components/MkYouTubePlayer.vue +++ b/packages/frontend/src/components/MkYouTubePlayer.vue @@ -24,6 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> +import { ref } from 'vue'; import MkWindow from '@/components/MkWindow.vue'; import { versatileLang } from '@/scripts/intl-const.js'; import { defaultStore } from '@/store.js'; @@ -35,22 +36,22 @@ const props = defineProps<{ const requestUrl = new URL(props.url); if (!['http:', 'https:'].includes(requestUrl.protocol)) throw new Error('invalid url'); -let fetching = $ref(true); -let title = $ref<string | null>(null); -let player = $ref({ +const fetching = ref(true); +const title = ref<string | null>(null); +const player = ref({ url: null, width: null, height: null, }); const ytFetch = (): void => { - fetching = true; + fetching.value = true; window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`).then(res => { res.json().then(info => { if (info.url == null) return; - title = info.title; - fetching = false; - player = info.player; + title.value = info.title; + fetching.value = false; + player.value = info.player; }); }); }; diff --git a/packages/frontend/src/components/SkApprovalUser.vue b/packages/frontend/src/components/SkApprovalUser.vue index 99dcb717b1..2bf6361ac8 100644 --- a/packages/frontend/src/components/SkApprovalUser.vue +++ b/packages/frontend/src/components/SkApprovalUser.vue @@ -27,6 +27,7 @@ </template> <script lang="ts" setup> +import { ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkFolder from '@/components/MkFolder.vue'; import MkButton from '@/components/MkButton.vue'; @@ -37,15 +38,15 @@ const props = defineProps<{ user: Misskey.entities.User; }>(); -let reason = $ref(''); -let email = $ref(''); +let reason = ref(''); +let email = ref(''); function getReason() { return os.api('admin/show-user', { userId: props.user.id, }).then(info => { - reason = info?.signupReason; - email = info?.email; + reason.value = info?.signupReason; + email.value = info?.email; }); } diff --git a/packages/frontend/src/components/SkInstanceTicker.vue b/packages/frontend/src/components/SkInstanceTicker.vue index 4e2856388e..fa7b2a444d 100644 --- a/packages/frontend/src/components/SkInstanceTicker.vue +++ b/packages/frontend/src/components/SkInstanceTicker.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { } from 'vue'; +import { computed } from 'vue'; import { instanceName } from '@/config.js'; import { instance as Instance } from '@/instance.js'; import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js'; @@ -30,7 +30,7 @@ const instance = props.instance ?? { themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content, }; -const faviconUrl = $computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(Instance.faviconUrl, 'preview') ?? '/favicon.ico'); +const faviconUrl = computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(Instance.faviconUrl, 'preview') ?? '/favicon.ico'); const themeColor = instance.themeColor ?? '#777777'; diff --git a/packages/frontend/src/components/SkNote.vue b/packages/frontend/src/components/SkNote.vue index b308f4a07a..cb37861330 100644 --- a/packages/frontend/src/components/SkNote.vue +++ b/packages/frontend/src/components/SkNote.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div - v-if="!muted" + v-if="!hardMuted && !muted" v-show="!isDeleted" ref="el" v-hotkey="keymap" @@ -58,9 +58,9 @@ SPDX-License-Identifier: AGPL-3.0-only <div style="container-type: inline-size;"> <p v-if="appearNote.cw != null" :class="$style.cw"> <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/> - <MkCwButton v-model="showContent" :note="appearNote" style="margin: 4px 0;" v-on:click.stop/> + <MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;" @click.stop/> </p> - <div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]" > + <div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]"> <div :class="$style.text"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <Mfm @@ -81,31 +81,31 @@ SPDX-License-Identifier: AGPL-3.0-only <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> </div> </div> - <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> - <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> + <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> + <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> </div> <div v-if="appearNote.files.length > 0"> - <MkMediaList :mediaList="appearNote.files" v-on:click.stop/> + <MkMediaList :mediaList="appearNote.files" @click.stop/> </div> - <MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll" v-on:click.stop /> - <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" v-on:click.stop/> + <MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll" @click.stop/> + <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview" @click.stop/> <div v-if="appearNote.renote" :class="$style.quote"><SkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> - <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" v-on:click.stop @click="collapsed = false"> + <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click.stop @click="collapsed = false"> <span :class="$style.collapsedLabel">{{ i18n.ts.showMore }}</span> </button> - <button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" v-on:click.stop @click="collapsed = true"> + <button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click.stop @click="collapsed = true"> <span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span> </button> </div> <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA> </div> - <MkReactionsViewer :note="appearNote" :maxNumber="16" v-on:click.stop @mockUpdateMyReaction="emitUpdReaction"> + <MkReactionsViewer :note="appearNote" :maxNumber="16" @click.stop @mockUpdateMyReaction="emitUpdReaction"> <template #more> <div :class="$style.reactionOmitted">{{ i18n.ts.more }}</div> </template> </MkReactionsViewer> <footer :class="$style.footer"> - <button :class="$style.footerButton" class="_button" v-on:click.stop @click="reply()"> + <button :class="$style.footerButton" class="_button" @click.stop @click="reply()"> <i class="ph-arrow-u-up-left ph-bold ph-lg"></i> <p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ appearNote.repliesCount }}</p> </button> @@ -115,7 +115,7 @@ SPDX-License-Identifier: AGPL-3.0-only :class="$style.footerButton" class="_button" :style="renoted ? 'color: var(--accent) !important;' : ''" - v-on:click.stop + @click.stop @mousedown="renoted ? undoRenote(appearNote) : boostVisibility()" > <i class="ph-rocket-launch ph-bold ph-lg"></i> @@ -129,19 +129,19 @@ SPDX-License-Identifier: AGPL-3.0-only ref="quoteButton" :class="$style.footerButton" class="_button" - v-on:click.stop + @click.stop @mousedown="quote()" > <i class="ph-quotes ph-bold ph-lg"></i> </button> - <button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.footerButton" class="_button" v-on:click.stop @click="like()"> + <button v-if="appearNote.myReaction == null && appearNote.reactionAcceptance !== 'likeOnly'" ref="likeButton" :class="$style.footerButton" class="_button" @click.stop @click="like()"> <i class="ph-heart ph-bold ph-lg"></i> </button> <button v-if="appearNote.myReaction == null" ref="reactButton" :class="$style.footerButton" class="_button" @mousedown="react()"> <i v-if="appearNote.reactionAcceptance === 'likeOnly'" class="ph-heart ph-bold ph-lg"></i> <i v-else class="ph-smiley ph-bold ph-lg"></i> </button> - <button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" v-on:click.stop @click="undoReact(appearNote)"> + <button v-if="appearNote.myReaction != null" ref="reactButton" :class="$style.footerButton" class="_button" @click.stop @click="undoReact(appearNote)"> <i class="ph-minus ph-bold ph-lg"></i> </button> <button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()"> @@ -154,7 +154,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </article> </div> -<div v-else :class="$style.muted" @click="muted = false"> +<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false"> <I18n :src="i18n.ts.userSaysSomething" tag="small"> <template #name> <MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)"> @@ -163,10 +163,16 @@ SPDX-License-Identifier: AGPL-3.0-only </template> </I18n> </div> +<div v-else> + <!-- + MkDateSeparatedList uses TransitionGroup which requires single element in the child elements + so MkNote create empty div instead of no elements + --> +</div> </template> <script lang="ts" setup> -import { computed, inject, onMounted, ref, shallowRef, Ref, defineAsyncComponent, watch, provide } from 'vue'; +import { computed, inject, onMounted, ref, shallowRef, Ref, watch, provide } from 'vue'; import * as mfm from '@sharkey/sfm-js'; import * as Misskey from 'misskey-js'; import SkNoteSub from '@/components/SkNoteSub.vue'; @@ -184,6 +190,7 @@ import { focusPrev, focusNext } from '@/scripts/focus.js'; import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import * as os from '@/os.js'; +import * as sound from '@/scripts/sound.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; @@ -207,6 +214,7 @@ const props = withDefaults(defineProps<{ note: Misskey.entities.Note; pinned?: boolean; mock?: boolean; + withHardMute?: boolean; }>(), { mock: false, }); @@ -223,7 +231,7 @@ const router = useRouter(); const inChannel = inject('inChannel', null); const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', null); -let note = $ref(deepClone(props.note)); +const note = ref(deepClone(props.note)); function noteclick(id: string) { const selection = document.getSelection(); @@ -235,7 +243,7 @@ function noteclick(id: string) { // plugin if (noteViewInterruptors.length > 0) { onMounted(async () => { - let result: Misskey.entities.Note | null = deepClone(note); + let result: Misskey.entities.Note | null = deepClone(note.value); for (const interruptor of noteViewInterruptors) { try { result = await interruptor.handler(result); @@ -247,15 +255,16 @@ if (noteViewInterruptors.length > 0) { console.error(err); } } - note = result; + note.value = result; }); } const isRenote = ( - note.renote != null && - note.text == null && - note.fileIds.length === 0 && - note.poll == null + note.value.renote != null && + note.value.text == null && + note.value.cw == null && + note.value.fileIds.length === 0 && + note.value.poll == null ); const el = shallowRef<HTMLElement>(); @@ -267,27 +276,37 @@ const reactButton = shallowRef<HTMLElement>(); const quoteButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); -let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note); -const renoteUrl = appearNote.renote ? appearNote.renote.url : null; -const renoteUri = appearNote.renote ? appearNote.renote.uri : null; +const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); +const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null; +const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null; -const isMyRenote = $i && ($i.id === note.userId); +const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); -const parsed = $computed(() => appearNote.text ? mfm.parse(appearNote.text) : null); -const urls = $computed(() => parsed ? extractUrlFromMfm(parsed).filter(u => u !== renoteUrl && u !== renoteUri) : null); -const animated = $computed(() => parsed ? checkAnimationFromMfm(parsed) : null); -const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); -const isLong = shouldCollapsed(appearNote, urls ?? []); -const collapsed = defaultStore.state.expandLongNote && appearNote.cw == null ? false : ref(appearNote.cw == null && isLong); +const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text).filter(u => u !== renoteUrl && u !== renoteUri) : null); +const urls = computed(() => parsed.value ? extractUrlFromMfm(parsed.value) : null); +const isLong = shouldCollapsed(appearNote.value, urls.value ?? []); +const collapsed = defaultStore.state.expandLongNote && appearNote.value.cw == null ? false : ref(appearNote.value.cw == null && isLong); const isDeleted = ref(false); const renoted = ref(false); -const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false); +const muted = ref(checkMute(appearNote.value, $i?.mutedWords)); +const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords)); const translation = ref<any>(null); const translating = ref(false); -const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i.id)); -let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null))); +const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i.id)); +const renoteCollapsed = ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || (appearNote.value.myReaction != null))); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); +const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); +const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); + +function checkMute(note: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null): boolean { + if (mutedWords == null) return false; + + if (checkWordMute(note, $i, mutedWords)) return true; + if (note.reply && checkWordMute(note.reply, $i, mutedWords)) return true; + if (note.renote && checkWordMute(note.renote, $i, mutedWords)) return true; + return false; +} const keymap = { 'r': () => reply(true), @@ -302,20 +321,20 @@ const keymap = { provide('react', (reaction: string) => { os.api('notes/reactions/create', { - noteId: appearNote.id, + noteId: appearNote.value.id, reaction: reaction, }); }); if (props.mock) { watch(() => props.note, (to) => { - note = deepClone(to); + note.value = deepClone(to); }, { deep: true }); } else { useNoteCapture({ rootEl: el, - note: $$(appearNote), - pureNote: $$(note), + note: appearNote, + pureNote: note, isDeletedRef: isDeleted, }); } @@ -323,7 +342,7 @@ if (props.mock) { if (!props.mock) { useTooltip(renoteButton, async (showing) => { const renotes = await os.api('notes/renotes', { - noteId: appearNote.id, + noteId: appearNote.value.id, limit: 11, }); @@ -334,14 +353,14 @@ if (!props.mock) { os.popup(MkUsersTooltip, { showing, users, - count: appearNote.renoteCount, + count: appearNote.value.renoteCount, targetElement: renoteButton.value, }, {}, 'closed'); }); useTooltip(quoteButton, async (showing) => { const renotes = await os.api('notes/renotes', { - noteId: appearNote.id, + noteId: appearNote.value.id, limit: 11, quote: true, }); @@ -353,14 +372,14 @@ if (!props.mock) { os.popup(MkUsersTooltip, { showing, users, - count: appearNote.renoteCount, + count: appearNote.value.renoteCount, targetElement: quoteButton.value, }, {}, 'closed'); }); if ($i) { - os.api("notes/renotes", { - noteId: appearNote.id, + os.api('notes/renotes', { + noteId: appearNote.value.id, userId: $i.id, limit: 1, }).then((res) => { @@ -420,7 +439,7 @@ function renote(visibility: Visibility | 'local') { pleaseLogin(); showMovedDialog(); - if (appearNote.channel) { + if (appearNote.value.channel) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -431,14 +450,14 @@ function renote(visibility: Visibility | 'local') { if (!props.mock) { os.api('notes/create', { - renoteId: appearNote.id, - channelId: appearNote.channelId, + renoteId: appearNote.value.id, + channelId: appearNote.value.channelId, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; }); } - } else if (!appearNote.channel || appearNote.channel?.allowRenoteToExternal) { + } else if (!appearNote.value.channel || appearNote.value.channel?.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -450,16 +469,16 @@ function renote(visibility: Visibility | 'local') { const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; const localOnlySetting = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; - let noteVisibility = visibility === 'local' || visibility === 'specified' ? smallerVisibility(appearNote.visibility, configuredVisibility) : smallerVisibility(visibility, configuredVisibility); - if (appearNote.channel?.isSensitive) { - noteVisibility = smallerVisibility(visibility === 'local' || visibility === 'specified' ? appearNote.visibility : visibility, 'home'); + let noteVisibility = visibility === 'local' || visibility === 'specified' ? smallerVisibility(appearNote.value.visibility, configuredVisibility) : smallerVisibility(visibility, configuredVisibility); + if (appearNote.value.channel?.isSensitive) { + noteVisibility = smallerVisibility(visibility === 'local' || visibility === 'specified' ? appearNote.value.visibility : visibility, 'home'); } if (!props.mock) { os.api('notes/create', { localOnly: visibility === 'local' ? true : localOnlySetting, visibility: noteVisibility, - renoteId: appearNote.id, + renoteId: appearNote.value.id, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; @@ -475,13 +494,13 @@ function quote() { return; } - if (appearNote.channel) { + if (appearNote.value.channel) { os.post({ - renote: appearNote, - channel: appearNote.channel, + renote: appearNote.value, + channel: appearNote.value.channel, }).then(() => { - os.api("notes/renotes", { - noteId: appearNote.id, + os.api('notes/renotes', { + noteId: appearNote.value.id, userId: $i.id, limit: 1, quote: true, @@ -500,10 +519,10 @@ function quote() { }); } else { os.post({ - renote: appearNote, + renote: appearNote.value, }).then(() => { - os.api("notes/renotes", { - noteId: appearNote.id, + os.api('notes/renotes', { + noteId: appearNote.value.id, userId: $i.id, limit: 1, quote: true, @@ -529,8 +548,8 @@ function reply(viaKeyboard = false): void { return; } os.post({ - reply: appearNote, - channel: appearNote.channel, + reply: appearNote.value, + channel: appearNote.value.channel, animation: !viaKeyboard, }, () => { focus(); @@ -544,7 +563,7 @@ function like(): void { return; } os.api('notes/like', { - noteId: appearNote.id, + noteId: appearNote.value.id, override: defaultLike.value, }); const el = likeButton.value as HTMLElement | null | undefined; @@ -559,13 +578,15 @@ function like(): void { function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); - if (appearNote.reactionAcceptance === 'likeOnly') { + if (appearNote.value.reactionAcceptance === 'likeOnly') { + sound.play('reaction'); + if (props.mock) { return; } os.api('notes/like', { - noteId: appearNote.id, + noteId: appearNote.value.id, override: defaultLike.value, }); const el = reactButton.value as HTMLElement | null | undefined; @@ -578,16 +599,18 @@ function react(viaKeyboard = false): void { } else { blur(); reactionPicker.show(reactButton.value, reaction => { + sound.play('reaction'); + if (props.mock) { emit('reaction', reaction); return; } os.api('notes/reactions/create', { - noteId: appearNote.id, + noteId: appearNote.value.id, reaction: reaction, }); - if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { + if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) { claimAchievement('reactWithoutRead'); } }, () => { @@ -614,8 +637,8 @@ function undoRenote(note) : void { if (props.mock) { return; } - os.api("notes/unrenote", { - noteId: note.id + os.api('notes/unrenote', { + noteId: note.id, }); os.toast(i18n.ts.rmboost); renoted.value = false; @@ -649,7 +672,7 @@ function onContextmenu(ev: MouseEvent): void { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } @@ -659,14 +682,14 @@ function menu(viaKeyboard = false): void { return; } - const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted, currentClip: currentClip?.value }); os.popupMenu(menu, menuButton.value, { viaKeyboard, }).then(focus).finally(cleanup); } async function menuVersions(viaKeyboard = false): Promise<void> { - const { menu, cleanup } = await getNoteVersionsMenu({ note: note, menuVersionsButton }); + const { menu, cleanup } = await getNoteVersionsMenu({ note: note.value, menuVersionsButton }); os.popupMenu(menu, menuVersionsButton.value, { viaKeyboard, }).then(focus).finally(cleanup); @@ -677,7 +700,7 @@ async function clip() { return; } - os.popupMenu(await getNoteClipMenu({ note: note, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); + os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus); } function showRenoteMenu(viaKeyboard = false): void { @@ -692,7 +715,7 @@ function showRenoteMenu(viaKeyboard = false): void { danger: true, action: () => { os.api('notes/delete', { - noteId: note.id, + noteId: note.value.id, }); isDeleted.value = true; }, @@ -702,17 +725,17 @@ function showRenoteMenu(viaKeyboard = false): void { if (isMyRenote) { pleaseLogin(); os.popupMenu([ - getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote), - null, + getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), + { type: 'divider' }, getUnrenote(), ], renoteTime.value, { viaKeyboard: viaKeyboard, }); } else { os.popupMenu([ - getCopyNoteLinkMenu(note, i18n.ts.copyLinkRenote), - null, - getAbuseNoteMenu(note, i18n.ts.reportAbuseRenote), + getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote), + { type: 'divider' }, + getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote), $i.isModerator || $i.isAdmin ? getUnrenote() : undefined, ], renoteTime.value, { viaKeyboard: viaKeyboard, @@ -750,7 +773,7 @@ function focusAfter() { function readPromo() { os.api('promo/read', { - noteId: appearNote.id, + noteId: appearNote.value.id, }); isDeleted.value = true; } diff --git a/packages/frontend/src/components/SkNoteDetailed.vue b/packages/frontend/src/components/SkNoteDetailed.vue index 4699eba8f6..8bf9e244e0 100644 --- a/packages/frontend/src/components/SkNoteDetailed.vue +++ b/packages/frontend/src/components/SkNoteDetailed.vue @@ -78,7 +78,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.noteContent"> <p v-if="appearNote.cw != null" :class="$style.cw"> <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/> - <MkCwButton v-model="showContent" :note="appearNote"/> + <MkCwButton v-model="showContent" :text="appearNote.text" :files="appearNote.files" :poll="appearNote.poll"/> </p> <div v-show="appearNote.cw == null || showContent"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> @@ -101,8 +101,8 @@ SPDX-License-Identifier: AGPL-3.0-only <Mfm :text="translation.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/> </div> </div> - <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> - <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" v-on:click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> + <MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton> + <MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton> <div v-if="appearNote.files.length > 0"> <MkMediaList :mediaList="appearNote.files"/> </div> @@ -245,6 +245,7 @@ import { checkWordMute } from '@/scripts/check-word-mute.js'; import { userPage } from '@/filters/user.js'; import { notePage } from '@/filters/note.js'; import * as os from '@/os.js'; +import * as sound from '@/scripts/sound.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { reactionPicker } from '@/scripts/reaction-picker.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; @@ -256,12 +257,11 @@ import { useNoteCapture } from '@/scripts/use-note-capture.js'; import { deepClone } from '@/scripts/clone.js'; import { useTooltip } from '@/scripts/use-tooltip.js'; import { claimAchievement } from '@/scripts/achievements.js'; -import { MenuItem } from '@/types/menu.js'; import { checkAnimationFromMfm } from '@/scripts/check-animated-mfm.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; -import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import MkPagination from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; @@ -272,12 +272,12 @@ const props = defineProps<{ const inChannel = inject('inChannel', null); -let note = $ref(deepClone(props.note)); +const note = ref(deepClone(props.note)); // plugin if (noteViewInterruptors.length > 0) { onMounted(async () => { - let result: Misskey.entities.Note | null = deepClone(note); + let result: Misskey.entities.Note | null = deepClone(note.value); for (const interruptor of noteViewInterruptors) { try { result = await interruptor.handler(result); @@ -289,15 +289,15 @@ if (noteViewInterruptors.length > 0) { console.error(err); } } - note = result; + note.value = result; }); } const isRenote = ( - note.renote != null && - note.text == null && - note.fileIds.length === 0 && - note.poll == null + note.value.renote != null && + note.value.text == null && + note.value.fileIds.length === 0 && + note.value.poll == null ); const el = shallowRef<HTMLElement>(); @@ -309,26 +309,25 @@ const reactButton = shallowRef<HTMLElement>(); const quoteButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); -let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note); -const renoteUrl = appearNote.renote ? appearNote.renote.url : null; -const renoteUri = appearNote.renote ? appearNote.renote.uri : null; - -const isMyRenote = $i && ($i.id === note.userId); +const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); +const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null; +const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null; +const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); const isDeleted = ref(false); const renoted = ref(false); -const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false); +const muted = ref($i ? checkWordMute(appearNote.value, $i, $i.mutedWords) : false); const translation = ref(null); const translating = ref(false); -const parsed = $computed(() => appearNote.text ? mfm.parse(appearNote.text) : null); +const parsed = appearNote.value.text ? mfm.parse(appearNote.value.text) : null; const urls = parsed ? extractUrlFromMfm(parsed).filter(u => u !== renoteUrl && u !== renoteUri) : null; -const animated = $computed(() => parsed ? checkAnimationFromMfm(parsed) : null); +const animated = computed(() => parsed ? checkAnimationFromMfm(parsed) : null); const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); -const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); +const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); const conversation = ref<Misskey.entities.Note[]>([]); const replies = ref<Misskey.entities.Note[]>([]); const quotes = ref<Misskey.entities.Note[]>([]); -const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id); +const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i.id); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); watch(() => props.expandAllCws, (expandAllCws) => { @@ -336,8 +335,8 @@ watch(() => props.expandAllCws, (expandAllCws) => { }); if ($i) { - os.api("notes/renotes", { - noteId: appearNote.id, + os.api('notes/renotes', { + noteId: appearNote.value.id, userId: $i.id, limit: 1, }).then((res) => { @@ -356,41 +355,41 @@ const keymap = { provide('react', (reaction: string) => { os.api('notes/reactions/create', { - noteId: appearNote.id, + noteId: appearNote.value.id, reaction: reaction, }); }); -let tab = $ref('replies'); -let reactionTabType = $ref(null); +const tab = ref('replies'); +const reactionTabType = ref(null); -const renotesPagination = $computed(() => ({ +const renotesPagination = computed(() => ({ endpoint: 'notes/renotes', limit: 10, params: { - noteId: appearNote.id, + noteId: appearNote.value.id, }, })); -const reactionsPagination = $computed(() => ({ +const reactionsPagination = computed(() => ({ endpoint: 'notes/reactions', limit: 10, params: { - noteId: appearNote.id, - type: reactionTabType, + noteId: appearNote.value.id, + type: reactionTabType.value, }, })); useNoteCapture({ rootEl: el, - note: $$(appearNote), - pureNote: $$(note), + note: appearNote, + pureNote: note, isDeletedRef: isDeleted, }); useTooltip(renoteButton, async (showing) => { const renotes = await os.api('notes/renotes', { - noteId: appearNote.id, + noteId: appearNote.value.id, limit: 11, }); @@ -401,14 +400,14 @@ useTooltip(renoteButton, async (showing) => { os.popup(MkUsersTooltip, { showing, users, - count: appearNote.renoteCount, + count: appearNote.value.renoteCount, targetElement: renoteButton.value, }, {}, 'closed'); }); useTooltip(quoteButton, async (showing) => { const renotes = await os.api('notes/renotes', { - noteId: appearNote.id, + noteId: appearNote.value.id, limit: 11, quote: true, }); @@ -420,7 +419,7 @@ useTooltip(quoteButton, async (showing) => { os.popup(MkUsersTooltip, { showing, users, - count: appearNote.renoteCount, + count: appearNote.value.renoteCount, targetElement: quoteButton.value, }, {}, 'closed'); }); @@ -475,7 +474,7 @@ function renote(visibility: Visibility | 'local') { pleaseLogin(); showMovedDialog(); - if (appearNote.channel) { + if (appearNote.value.channel) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -485,13 +484,13 @@ function renote(visibility: Visibility | 'local') { } os.api('notes/create', { - renoteId: appearNote.id, - channelId: appearNote.channelId, + renoteId: appearNote.value.id, + channelId: appearNote.value.channelId, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; }); - } else if (!appearNote.channel || appearNote.channel?.allowRenoteToExternal) { + } else if (!appearNote.value.channel || appearNote.value.channel?.allowRenoteToExternal) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -503,15 +502,15 @@ function renote(visibility: Visibility | 'local') { const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility; const localOnlySetting = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly; - let noteVisibility = visibility === 'local' || visibility === 'specified' ? smallerVisibility(appearNote.visibility, configuredVisibility) : smallerVisibility(visibility, configuredVisibility); - if (appearNote.channel?.isSensitive) { - noteVisibility = smallerVisibility(visibility === 'local' || visibility === 'specified' ? appearNote.visibility : visibility, 'home'); + let noteVisibility = visibility === 'local' || visibility === 'specified' ? smallerVisibility(appearNote.value.visibility, configuredVisibility) : smallerVisibility(visibility, configuredVisibility); + if (appearNote.value.channel?.isSensitive) { + noteVisibility = smallerVisibility(visibility === 'local' || visibility === 'specified' ? appearNote.value.visibility : visibility, 'home'); } os.api('notes/create', { localOnly: visibility === 'local' ? true : localOnlySetting, visibility: noteVisibility, - renoteId: appearNote.id, + renoteId: appearNote.value.id, }).then(() => { os.toast(i18n.ts.renoted); renoted.value = true; @@ -523,13 +522,13 @@ function quote() { pleaseLogin(); showMovedDialog(); - if (appearNote.channel) { + if (appearNote.value.channel) { os.post({ - renote: appearNote, - channel: appearNote.channel, + renote: appearNote.value, + channel: appearNote.value.channel, }).then(() => { - os.api("notes/renotes", { - noteId: appearNote.id, + os.api('notes/renotes', { + noteId: appearNote.value.id, userId: $i.id, limit: 1, quote: true, @@ -548,10 +547,10 @@ function quote() { }); } else { os.post({ - renote: appearNote, + renote: appearNote.value, }).then(() => { - os.api("notes/renotes", { - noteId: appearNote.id, + os.api('notes/renotes', { + noteId: appearNote.value.id, userId: $i.id, limit: 1, quote: true, @@ -575,8 +574,8 @@ function reply(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); os.post({ - reply: appearNote, - channel: appearNote.channel, + reply: appearNote.value, + channel: appearNote.value.channel, animation: !viaKeyboard, }, () => { focus(); @@ -586,9 +585,9 @@ function reply(viaKeyboard = false): void { function react(viaKeyboard = false): void { pleaseLogin(); showMovedDialog(); - if (appearNote.reactionAcceptance === 'likeOnly') { + if (appearNote.value.reactionAcceptance === 'likeOnly') { os.api('notes/like', { - noteId: appearNote.id, + noteId: appearNote.value.id, override: defaultLike.value, }); const el = reactButton.value as HTMLElement | null | undefined; @@ -601,11 +600,13 @@ function react(viaKeyboard = false): void { } else { blur(); reactionPicker.show(reactButton.value, reaction => { + sound.play('reaction'); + os.api('notes/reactions/create', { - noteId: appearNote.id, + noteId: appearNote.value.id, reaction: reaction, }); - if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { + if (appearNote.value.text && appearNote.value.text.length > 100 && (Date.now() - new Date(appearNote.value.createdAt).getTime() < 1000 * 3)) { claimAchievement('reactWithoutRead'); } }, () => { @@ -618,7 +619,7 @@ function like(): void { pleaseLogin(); showMovedDialog(); os.api('notes/like', { - noteId: appearNote.id, + noteId: appearNote.value.id, override: defaultLike.value, }); const el = likeButton.value as HTMLElement | null | undefined; @@ -640,8 +641,8 @@ function undoReact(note): void { function undoRenote() : void { if (!renoted.value) return; - os.api("notes/unrenote", { - noteId: appearNote.id, + os.api('notes/unrenote', { + noteId: appearNote.value.id, }); os.toast(i18n.ts.rmboost); renoted.value = false; @@ -669,27 +670,27 @@ function onContextmenu(ev: MouseEvent): void { ev.preventDefault(); react(); } else { - const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted }); os.contextMenu(menu, ev).then(focus).finally(cleanup); } } function menu(viaKeyboard = false): void { - const { menu, cleanup } = getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }); + const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, menuButton, isDeleted }); os.popupMenu(menu, menuButton.value, { viaKeyboard, }).then(focus).finally(cleanup); } async function menuVersions(viaKeyboard = false): Promise<void> { - const { menu, cleanup } = await getNoteVersionsMenu({ note: note, menuVersionsButton }); + const { menu, cleanup } = await getNoteVersionsMenu({ note: note.value, menuVersionsButton }); os.popupMenu(menu, menuVersionsButton.value, { viaKeyboard, }).then(focus).finally(cleanup); } async function clip() { - os.popupMenu(await getNoteClipMenu({ note: note, isDeleted }), clipButton.value).then(focus); + os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus); } function showRenoteMenu(viaKeyboard = false): void { @@ -701,7 +702,7 @@ function showRenoteMenu(viaKeyboard = false): void { danger: true, action: () => { os.api('notes/delete', { - noteId: note.id, + noteId: note.value.id, }); isDeleted.value = true; }, @@ -723,7 +724,7 @@ const repliesLoaded = ref(false); function loadReplies() { repliesLoaded.value = true; os.api('notes/children', { - noteId: appearNote.id, + noteId: appearNote.value.id, limit: 30, showQuotes: false, }).then(res => { @@ -738,7 +739,7 @@ const quotesLoaded = ref(false); function loadQuotes() { quotesLoaded.value = true; os.api('notes/renotes', { - noteId: appearNote.id, + noteId: appearNote.value.id, limit: 30, quote: true, }).then(res => { @@ -753,13 +754,13 @@ const conversationLoaded = ref(false); function loadConversation() { conversationLoaded.value = true; os.api('notes/conversation', { - noteId: appearNote.replyId, + noteId: appearNote.value.replyId, }).then(res => { conversation.value = res.reverse(); }); } -if (appearNote.reply && appearNote.reply.replyId && defaultStore.state.autoloadConversation) loadConversation(); +if (appearNote.value.reply && appearNote.value.reply.replyId && defaultStore.state.autoloadConversation) loadConversation(); function animatedMFM() { if (allowAnim.value) { diff --git a/packages/frontend/src/components/SkNoteSimple.vue b/packages/frontend/src/components/SkNoteSimple.vue index 05a19e291d..fe12baedeb 100644 --- a/packages/frontend/src/components/SkNoteSimple.vue +++ b/packages/frontend/src/components/SkNoteSimple.vue @@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div> <p v-if="note.cw != null" :class="$style.cw"> <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/> - <MkCwButton v-model="showContent" :note="note" v-on:click.stop/> + <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/> </p> <div v-show="note.cw == null || showContent"> <MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note"/> @@ -22,12 +22,11 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { watch } from 'vue'; +import { watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import MkNoteHeader from '@/components/MkNoteHeader.vue'; import MkSubNoteContent from '@/components/MkSubNoteContent.vue'; import MkCwButton from '@/components/MkCwButton.vue'; -import { $i } from '@/account.js'; import { defaultStore } from '@/store.js'; const props = defineProps<{ @@ -36,10 +35,10 @@ const props = defineProps<{ hideFiles?: boolean; }>(); -let showContent = $ref(defaultStore.state.uncollapseCW); +let showContent = ref(defaultStore.state.uncollapseCW); watch(() => props.expandAllCws, (expandAllCws) => { - if (expandAllCws !== showContent) showContent = expandAllCws; + if (expandAllCws !== showContent.value) showContent.value = expandAllCws; }); </script> diff --git a/packages/frontend/src/components/SkNoteSub.vue b/packages/frontend/src/components/SkNoteSub.vue index dd4abe8f58..fc30dc87aa 100644 --- a/packages/frontend/src/components/SkNoteSub.vue +++ b/packages/frontend/src/components/SkNoteSub.vue @@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div :class="$style.content"> <p v-if="note.cw != null" :class="$style.cw"> <Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'"/> - <MkCwButton v-model="showContent" :note="note"/> + <MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/> </p> <div v-show="note.cw == null || showContent"> <MkSubNoteContent :class="$style.text" :note="note" :translating="translating" :translation="translation"/> @@ -101,15 +101,14 @@ import { notePage } from '@/filters/note.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; -import { userPage } from "@/filters/user.js"; -import { checkWordMute } from "@/scripts/check-word-mute.js"; -import { defaultStore } from "@/store.js"; +import { userPage } from '@/filters/user.js'; +import { checkWordMute } from '@/scripts/check-word-mute.js'; +import { defaultStore } from '@/store.js'; import { pleaseLogin } from '@/scripts/please-login.js'; import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { reactionPicker } from '@/scripts/reaction-picker.js'; import { claimAchievement } from '@/scripts/achievements.js'; -import type { MenuItem } from '@/types/menu.js'; import { getNoteMenu } from '@/scripts/get-note-menu.js'; import { useNoteCapture } from '@/scripts/use-note-capture.js'; @@ -140,7 +139,7 @@ const quoteButton = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); -let appearNote = $computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note); +let appearNote = computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); const isRenote = ( @@ -152,13 +151,13 @@ const isRenote = ( useNoteCapture({ rootEl: el, - note: $$(appearNote), + note: appearNote, isDeletedRef: isDeleted, }); if ($i) { - os.api("notes/renotes", { - noteId: appearNote.id, + os.api('notes/renotes', { + noteId: appearNote.value.id, userId: $i.id, limit: 1, }).then((res) => { @@ -239,8 +238,8 @@ function undoReact(note): void { function undoRenote() : void { if (!renoted.value) return; - os.api("notes/unrenote", { - noteId: appearNote.id, + os.api('notes/unrenote', { + noteId: appearNote.value.id, }); os.toast(i18n.ts.rmboost); renoted.value = false; @@ -254,13 +253,13 @@ function undoRenote() : void { } } -let showContent = $ref(defaultStore.state.uncollapseCW); +let showContent = ref(defaultStore.state.uncollapseCW); watch(() => props.expandAllCws, (expandAllCws) => { - if (expandAllCws !== showContent) showContent = expandAllCws; + if (expandAllCws !== showContent.value) showContent.value = expandAllCws; }); -let replies: Misskey.entities.Note[] = $ref([]); +let replies = ref<Misskey.entities.Note[]>([]); function boostVisibility() { os.popupMenu([ @@ -302,7 +301,7 @@ function renote(visibility: 'public' | 'home' | 'followers' | 'specified' | 'loc pleaseLogin(); showMovedDialog(); - if (appearNote.channel) { + if (appearNote.value.channel) { const el = renoteButton.value as HTMLElement | null | undefined; if (el) { const rect = el.getBoundingClientRect(); @@ -342,12 +341,12 @@ function quote() { pleaseLogin(); showMovedDialog(); - if (appearNote.channel) { + if (appearNote.value.channel) { os.post({ - renote: appearNote, - channel: appearNote.channel, + renote: appearNote.value, + channel: appearNote.value.channel, }).then(() => { - os.api("notes/renotes", { + os.api('notes/renotes', { noteId: props.note.id, userId: $i.id, limit: 1, @@ -367,9 +366,9 @@ function quote() { }); } else { os.post({ - renote: appearNote, + renote: appearNote.value, }).then(() => { - os.api("notes/renotes", { + os.api('notes/renotes', { noteId: props.note.id, userId: $i.id, limit: 1, @@ -403,7 +402,7 @@ if (props.detail) { limit: numberOfReplies.value, showQuotes: false, }).then(res => { - replies = res; + replies.value = res; }); } </script> diff --git a/packages/frontend/src/components/SkOldNoteWindow.vue b/packages/frontend/src/components/SkOldNoteWindow.vue index 49fbd39812..237032c9d5 100644 --- a/packages/frontend/src/components/SkOldNoteWindow.vue +++ b/packages/frontend/src/components/SkOldNoteWindow.vue @@ -30,7 +30,7 @@ <div :class="$style.noteContent"> <p v-if="appearNote.cw != null" :class="$style.cw"> <Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'account'"/> - <MkCwButton v-model="showContent" :note="appearNote"/> + <MkCwButton v-model="showContent" :text="appearNote.text" :files="appearNote.files" :poll="appearNote.poll"/> </p> <div v-show="appearNote.cw == null || showContent"> <span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> @@ -76,7 +76,7 @@ </template> <script lang="ts" setup> -import { inject, onMounted, ref, shallowRef } from 'vue'; +import { inject, onMounted, ref, shallowRef, computed } from 'vue'; import * as mfm from '@sharkey/sfm-js'; import * as Misskey from 'misskey-js'; import MkNoteSimple from '@/components/MkNoteSimple.vue'; @@ -89,7 +89,6 @@ import MkInstanceTicker from '@/components/MkInstanceTicker.vue'; import { userPage } from '@/filters/user.js'; import { defaultStore, noteViewInterruptors } from '@/store.js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; -import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; import { deepClone } from '@/scripts/clone.js'; import { dateTimeFormat } from '@/scripts/intl-const.js'; @@ -106,42 +105,42 @@ const emit = defineEmits<{ const inChannel = inject('inChannel', null); -let note = $ref(deepClone(props.note)); +let note = ref(deepClone(props.note)); // plugin if (noteViewInterruptors.length > 0) { onMounted(async () => { - let result = deepClone(note); + let result = deepClone(note.value); for (const interruptor of noteViewInterruptors) { result = await interruptor.handler(result); } - note = result; + note.value = result; }); } const replaceContent = () => { - props.oldText ? note.text = props.oldText : undefined; - note.createdAt = props.updatedAt; + props.oldText ? note.value.text = props.oldText : undefined; + note.value.createdAt = props.updatedAt; }; replaceContent(); const isRenote = ( - note.renote != null && - note.text == null && - note.fileIds.length === 0 && - note.poll == null + note.value.renote != null && + note.value.text == null && + note.value.fileIds.length === 0 && + note.value.poll == null ); const el = shallowRef<HTMLElement>(); -let appearNote = $computed(() => isRenote ? note.renote as Misskey.entities.Note : note); -const renoteUrl = appearNote.renote ? appearNote.renote.url : null; -const renoteUri = appearNote.renote ? appearNote.renote.uri : null; +let appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note); +const renoteUrl = appearNote.value.renote ? appearNote.value.renote.url : null; +const renoteUri = appearNote.value.renote ? appearNote.value.renote.uri : null; const showContent = ref(false); const translation = ref(null); const translating = ref(false); -const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).filter(u => u !== renoteUrl && u !== renoteUri) : null; -const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); +const urls = appearNote.value.text ? extractUrlFromMfm(mfm.parse(appearNote.value.text)).filter(u => u !== renoteUrl && u !== renoteUri) : null; +const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance); </script> diff --git a/packages/frontend/src/components/form/section.vue b/packages/frontend/src/components/form/section.vue index 095b24604a..6af63d1ec6 100644 --- a/packages/frontend/src/components/form/section.vue +++ b/packages/frontend/src/components/form/section.vue @@ -6,6 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="[$style.root, { [$style.rootFirst]: first }]"> <div :class="[$style.label, { [$style.labelFirst]: first }]"><slot name="label"></slot></div> + <div :class="[$style.description]"><slot name="description"></slot></div> <div :class="$style.main"> <slot></slot> </div> @@ -31,7 +32,7 @@ defineProps<{ .label { font-weight: bold; padding: 1.5em 0 0 0; - margin: 0 0 16px 0; + margin: 0 0 8px 0; &:empty { display: none; @@ -45,4 +46,10 @@ defineProps<{ .main { margin: 1.5em 0 0 0; } + +.description { + font-size: 0.85em; + color: var(--fgTransparentWeak); + margin: 0 0 8px 0; +} </style> diff --git a/packages/frontend/src/components/form/suspense.vue b/packages/frontend/src/components/form/suspense.vue index f65f8a78ff..af5daa10ff 100644 --- a/packages/frontend/src/components/form/suspense.vue +++ b/packages/frontend/src/components/form/suspense.vue @@ -21,7 +21,6 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { ref, watch } from 'vue'; import MkButton from '@/components/MkButton.vue'; -import { defaultStore } from '@/store.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ diff --git a/packages/frontend/src/components/global/MkA.vue b/packages/frontend/src/components/global/MkA.vue index 7689bba7bf..e2b59869a4 100644 --- a/packages/frontend/src/components/global/MkA.vue +++ b/packages/frontend/src/components/global/MkA.vue @@ -4,16 +4,16 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<a :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu" v-on:click.stop> +<a :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu" @click.stop> <slot></slot> </a> </template> <script lang="ts" setup> +import { computed } from 'vue'; import * as os from '@/os.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import { url } from '@/config.js'; -import { popout as popout_ } from '@/scripts/popout.js'; import { i18n } from '@/i18n.js'; import { useRouter } from '@/router.js'; @@ -28,7 +28,7 @@ const props = withDefaults(defineProps<{ const router = useRouter(); -const active = $computed(() => { +const active = computed(() => { if (props.activeClass == null) return false; const resolved = router.resolve(props.to); if (resolved == null) return false; @@ -56,11 +56,11 @@ function onContextmenu(ev) { action: () => { router.push(props.to, 'forcePage'); }, - }, null, { + }, { type: 'divider' }, { icon: 'ph-arrow-square-out ph-bold ph-lg', text: i18n.ts.openInNewTab, action: () => { - window.open(props.to, '_blank'); + window.open(props.to, '_blank', 'noopener'); }, }, { icon: 'ph-link ph-bold ph-lg', diff --git a/packages/frontend/src/components/global/MkAd.stories.impl.ts b/packages/frontend/src/components/global/MkAd.stories.impl.ts index 360bc88b4a..5ae45ec58f 100644 --- a/packages/frontend/src/components/global/MkAd.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAd.stories.impl.ts @@ -4,11 +4,8 @@ */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { expect } from '@storybook/jest'; -import { userEvent, waitFor, within } from '@storybook/testing-library'; import { StoryObj } from '@storybook/vue3'; import MkAd from './MkAd.vue'; -import { i18n } from '@/i18n.js'; let lock: Promise<undefined> | undefined; diff --git a/packages/frontend/src/components/global/MkAd.vue b/packages/frontend/src/components/global/MkAd.vue index 3e092753a3..b3eb6d681f 100644 --- a/packages/frontend/src/components/global/MkAd.vue +++ b/packages/frontend/src/components/global/MkAd.vue @@ -96,7 +96,7 @@ const choseAd = (): Ad | null => { }; const chosen = ref(choseAd()); -const shouldHide = $ref(!defaultStore.state.forceShowAds && $i && $i.policies.canHideAds && (props.specify == null)); +const shouldHide = ref(!defaultStore.state.forceShowAds && $i && $i.policies.canHideAds && (props.specify == null)); function reduceFrequency(): void { if (chosen.value == null) return; diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index 01bf66fed5..4a876931c3 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -23,21 +23,24 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> </div> - <img - v-if="showDecoration && (decoration || user.avatarDecorations.length > 0)" - :class="[$style.decoration]" - :src="decoration?.url ?? user.avatarDecorations[0].url" - :style="{ - rotate: getDecorationAngle(), - scale: getDecorationScale(), - }" - alt="" - > + <template v-if="showDecoration"> + <img + v-for="decoration in decorations ?? user.avatarDecorations" + :class="[$style.decoration]" + :src="decoration.url" + :style="{ + rotate: getDecorationAngle(decoration), + scale: getDecorationScale(decoration), + translate: getDecorationOffset(decoration), + }" + alt="" + > + </template> </component> </template> <script lang="ts" setup> -import { watch } from 'vue'; +import { watch, ref, computed } from 'vue'; import * as Misskey from 'misskey-js'; import MkImgWithBlurhash from '../MkImgWithBlurhash.vue'; import MkA from './MkA.vue'; @@ -47,9 +50,9 @@ import { acct, userPage } from '@/filters/user.js'; import MkUserOnlineIndicator from '@/components/MkUserOnlineIndicator.vue'; import { defaultStore } from '@/store.js'; -const animation = $ref(defaultStore.state.animation); -const squareAvatars = $ref(defaultStore.state.squareAvatars); -const useBlurEffect = $ref(defaultStore.state.useBlurEffect); +const animation = ref(defaultStore.state.animation); +const squareAvatars = ref(defaultStore.state.squareAvatars); +const useBlurEffect = ref(defaultStore.state.useBlurEffect); const props = withDefaults(defineProps<{ user: Misskey.entities.User; @@ -57,19 +60,14 @@ const props = withDefaults(defineProps<{ link?: boolean; preview?: boolean; indicator?: boolean; - decoration?: { - url: string; - angle?: number; - flipH?: boolean; - flipV?: boolean; - }; + decorations?: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>[]; forceShowDecoration?: boolean; }>(), { target: null, link: false, preview: false, indicator: false, - decoration: undefined, + decorations: undefined, forceShowDecoration: false, }); @@ -79,11 +77,11 @@ const emit = defineEmits<{ const showDecoration = props.forceShowDecoration || defaultStore.state.showAvatarDecorations; -const bound = $computed(() => props.link +const bound = computed(() => props.link ? { to: userPage(props.user), target: props.target } : {}); -const url = $computed(() => (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.enableDataSaverMode) +const url = computed(() => (defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar) ? getStaticImageUrl(props.user.avatarUrl) : props.user.avatarUrl); @@ -92,34 +90,26 @@ function onClick(ev: MouseEvent): void { emit('click', ev); } -function getDecorationAngle() { - let angle; - if (props.decoration) { - angle = props.decoration.angle ?? 0; - } else if (props.user.avatarDecorations.length > 0) { - angle = props.user.avatarDecorations[0].angle ?? 0; - } else { - angle = 0; - } +function getDecorationAngle(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { + const angle = decoration.angle ?? 0; return angle === 0 ? undefined : `${angle * 360}deg`; } -function getDecorationScale() { - let scaleX; - if (props.decoration) { - scaleX = props.decoration.flipH ? -1 : 1; - } else if (props.user.avatarDecorations.length > 0) { - scaleX = props.user.avatarDecorations[0].flipH ? -1 : 1; - } else { - scaleX = 1; - } +function getDecorationScale(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { + const scaleX = decoration.flipH ? -1 : 1; return scaleX === 1 ? undefined : `${scaleX} 1`; } -let color = $ref<string | undefined>(); +function getDecorationOffset(decoration: Omit<Misskey.entities.UserDetailed['avatarDecorations'][number], 'id'>) { + const offsetX = decoration.offsetX ?? 0; + const offsetY = decoration.offsetY ?? 0; + return offsetX === 0 && offsetY === 0 ? undefined : `${offsetX * 100}% ${offsetY * 100}%`; +} + +const color = ref<string | undefined>(); watch(() => props.user.avatarBlurhash, () => { - color = extractAvgColorFromBlurhash(props.user.avatarBlurhash); + color.value = extractAvgColorFromBlurhash(props.user.avatarBlurhash); }, { immediate: true, }); diff --git a/packages/frontend/src/components/global/MkCustomEmoji.vue b/packages/frontend/src/components/global/MkCustomEmoji.vue index 10d7d93b01..e8732d1b16 100644 --- a/packages/frontend/src/components/global/MkCustomEmoji.vue +++ b/packages/frontend/src/components/global/MkCustomEmoji.vue @@ -19,12 +19,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, inject } from 'vue'; +import { computed, inject, ref } from 'vue'; import { getProxiedImageUrl, getStaticImageUrl } from '@/scripts/media-proxy.js'; import { defaultStore } from '@/store.js'; import { customEmojisMap } from '@/custom-emojis.js'; import * as os from '@/os.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import * as sound from '@/scripts/sound.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -71,7 +72,7 @@ const url = computed(() => { }); const alt = computed(() => `:${customEmojiName.value}:`); -let errored = $ref(url.value == null); +const errored = ref(url.value == null); function onClick(ev: MouseEvent) { if (props.menu) { @@ -90,6 +91,7 @@ function onClick(ev: MouseEvent) { icon: 'ph-smiley ph-bold ph-lg', action: () => { react(`:${props.name}:`); + sound.play('reaction'); }, }] : [])], ev.currentTarget ?? ev.target); } diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue index d5025edf82..b1d62db33c 100644 --- a/packages/frontend/src/components/global/MkEmoji.vue +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -16,6 +16,7 @@ import { defaultStore } from '@/store.js'; import { getEmojiName } from '@/scripts/emojilist.js'; import * as os from '@/os.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js'; +import * as sound from '@/scripts/sound.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ @@ -56,6 +57,7 @@ function onClick(ev: MouseEvent) { icon: 'ph-smiley ph-bold ph-lg', action: () => { react(props.emoji); + sound.play('reaction'); }, }] : [])], ev.currentTarget ?? ev.target); } diff --git a/packages/frontend/src/components/global/MkLazy.vue b/packages/frontend/src/components/global/MkLazy.vue new file mode 100644 index 0000000000..6d7ff4ca49 --- /dev/null +++ b/packages/frontend/src/components/global/MkLazy.vue @@ -0,0 +1,53 @@ +<!-- +SPDX-FileCopyrightText: syuilo and other misskey contributors +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div ref="rootEl" :class="$style.root"> + <div v-if="!showing" :class="$style.placeholder"></div> + <slot v-else></slot> +</div> +</template> + +<script lang="ts" setup> +import { nextTick, onMounted, onActivated, onBeforeUnmount, ref, shallowRef } from 'vue'; + +const rootEl = shallowRef<HTMLDivElement>(); +const showing = ref(false); + +const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + showing.value = true; + } + }, +); + +onMounted(() => { + nextTick(() => { + observer.observe(rootEl.value!); + }); +}); + +onActivated(() => { + nextTick(() => { + observer.observe(rootEl.value!); + }); +}); + +onBeforeUnmount(() => { + observer.disconnect(); +}); +</script> + +<style lang="scss" module> +.root { + display: block; +} + +.placeholder { + display: block; + min-height: 150px; +} +</style> diff --git a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts index bd6a599a98..60d12fdcde 100644 --- a/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts +++ b/packages/frontend/src/components/global/MkMisskeyFlavoredMarkdown.ts @@ -37,7 +37,7 @@ type MfmProps = { isNote?: boolean; emojiUrls?: string[]; rootScale?: number; - nyaize: boolean | 'respect'; + nyaize?: boolean | 'respect'; parsedNodes?: mfm.MfmNode[] | null; enableEmojiMenu?: boolean; enableEmojiMenuReaction?: boolean; @@ -110,26 +110,30 @@ export default function(props: MfmProps) { case 'fn': { // TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる - let style; + let style: string | undefined; switch (token.props.name) { case 'tada': { const speed = validTime(token.props.args.speed) ?? '1s'; - style = 'font-size: 150%;' + (useAnim ? `animation: tada ${speed} linear infinite both;` : ''); + const delay = validTime(token.props.args.delay) ?? '0s'; + style = 'font-size: 150%;' + (useAnim ? `animation: tada ${speed} linear infinite both; animation-delay: ${delay};` : ''); break; } case 'jelly': { const speed = validTime(token.props.args.speed) ?? '1s'; - style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both;` : ''); + const delay = validTime(token.props.args.delay) ?? '0s'; + style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both; animation-delay: ${delay};` : ''); break; } case 'twitch': { const speed = validTime(token.props.args.speed) ?? '0.5s'; - style = useAnim ? `animation: mfm-twitch ${speed} ease infinite;` : ''; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: mfm-twitch ${speed} ease infinite; animation-delay: ${delay};` : ''; break; } case 'shake': { const speed = validTime(token.props.args.speed) ?? '0.5s'; - style = useAnim ? `animation: mfm-shake ${speed} ease infinite;` : ''; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: mfm-shake ${speed} ease infinite; animation-delay: ${delay};` : ''; break; } case 'spin': { @@ -142,17 +146,20 @@ export default function(props: MfmProps) { token.props.args.y ? 'mfm-spinY' : 'mfm-spin'; const speed = validTime(token.props.args.speed) ?? '1.5s'; - style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : ''; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction}; animation-delay: ${delay};` : ''; break; } case 'jump': { const speed = validTime(token.props.args.speed) ?? '0.75s'; - style = useAnim ? `animation: mfm-jump ${speed} linear infinite;` : ''; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: mfm-jump ${speed} linear infinite; animation-delay: ${delay};` : ''; break; } case 'bounce': { const speed = validTime(token.props.args.speed) ?? '0.75s'; - style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : ''; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom; animation-delay: ${delay};` : ''; break; } case 'flip': { @@ -202,7 +209,8 @@ export default function(props: MfmProps) { }, genEl(token.children, scale)); } const speed = validTime(token.props.args.speed) ?? '1s'; - style = `animation: mfm-rainbow ${speed} linear infinite;`; + const delay = validTime(token.props.args.delay) ?? '0s'; + style = `animation: mfm-rainbow ${speed} linear infinite; animation-delay: ${delay};`; break; } case 'sparkle': { @@ -249,11 +257,17 @@ export default function(props: MfmProps) { case 'ruby': { if (token.children.length === 1) { const child = token.children[0]; - const text = child.type === 'text' ? child.props.text : ''; + let text = child.type === 'text' ? child.props.text : ''; + if (!disableNyaize && shouldNyaize) { + text = doNyaize(text); + } return h('ruby', {}, [text.split(' ')[0], h('rt', text.split(' ')[1])]); } else { const rt = token.children.at(-1)!; - const text = rt.type === 'text' ? rt.props.text : ''; + let text = rt.type === 'text' ? rt.props.text : ''; + if (!disableNyaize && shouldNyaize) { + text = doNyaize(text); + } return h('ruby', {}, [...genEl(token.children.slice(0, token.children.length - 1), scale), h('rt', text.trim())]); } } @@ -275,7 +289,7 @@ export default function(props: MfmProps) { ]); } } - if (style == null) { + if (style === undefined) { return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']); } else { return h('span', { diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index fd7aec5e5a..a36d9517cd 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -50,23 +50,19 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, ref, inject } from 'vue'; +import { onMounted, onUnmounted, ref, inject, shallowRef, computed } from 'vue'; import tinycolor from 'tinycolor2'; import XTabs, { Tab } from './MkPageHeader.tabs.vue'; import { scrollToTop } from '@/scripts/scroll.js'; import { globalEvents } from '@/events.js'; import { injectPageMetadata } from '@/scripts/page-metadata.js'; import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js'; +import { PageHeaderItem } from '@/types/page-header.js'; const props = withDefaults(defineProps<{ tabs?: Tab[]; tab?: string; - actions?: { - text: string; - icon: string; - highlighted?: boolean; - handler: (ev: MouseEvent) => void; - }[]; + actions?: PageHeaderItem[] | null; thin?: boolean; displayMyAvatar?: boolean; displayBackButton?: boolean; @@ -85,13 +81,13 @@ const metadata = injectPageMetadata(); const hideTitle = inject('shouldOmitHeaderTitle', false); const thin_ = props.thin || inject('shouldHeaderThin', false); -let el = $shallowRef<HTMLElement | undefined>(undefined); +const el = shallowRef<HTMLElement | undefined>(undefined); const bg = ref<string | undefined>(undefined); -let narrow = $ref(false); -const hasTabs = $computed(() => props.tabs.length > 0); -const hasActions = $computed(() => props.actions && props.actions.length > 0); -const show = $computed(() => { - return !hideTitle || hasTabs || hasActions; +const narrow = ref(false); +const hasTabs = computed(() => props.tabs.length > 0); +const hasActions = computed(() => props.actions && props.actions.length > 0); +const show = computed(() => { + return !hideTitle || hasTabs.value || hasActions.value; }); const preventDrag = (ev: TouchEvent) => { @@ -99,8 +95,8 @@ const preventDrag = (ev: TouchEvent) => { }; const top = () => { - if (el) { - scrollToTop(el as HTMLElement, { behavior: 'smooth' }); + if (el.value) { + scrollToTop(el.value as HTMLElement, { behavior: 'smooth' }); } }; @@ -131,14 +127,14 @@ onMounted(() => { calcBg(); globalEvents.on('themeChanged', calcBg); - if (el && el.parentElement) { - narrow = el.parentElement.offsetWidth < 500; + if (el.value && el.value.parentElement) { + narrow.value = el.value.parentElement.offsetWidth < 500; ro = new ResizeObserver((entries, observer) => { - if (el && el.parentElement && document.body.contains(el as HTMLElement)) { - narrow = el.parentElement.offsetWidth < 500; + if (el.value && el.value.parentElement && document.body.contains(el.value as HTMLElement)) { + narrow.value = el.value.parentElement.offsetWidth < 500; } }); - ro.observe(el.parentElement as HTMLElement); + ro.observe(el.value.parentElement as HTMLElement); } }); diff --git a/packages/frontend/src/components/global/MkStickyContainer.vue b/packages/frontend/src/components/global/MkStickyContainer.vue index 8e9bff11d1..1d707af2d1 100644 --- a/packages/frontend/src/components/global/MkStickyContainer.vue +++ b/packages/frontend/src/components/global/MkStickyContainer.vue @@ -18,36 +18,36 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, onUnmounted, provide, inject, Ref, ref, watch } from 'vue'; -import { $$ } from 'vue/macros'; +import { onMounted, onUnmounted, provide, inject, Ref, ref, watch, shallowRef } from 'vue'; + import { CURRENT_STICKY_BOTTOM, CURRENT_STICKY_TOP } from '@/const'; -const rootEl = $shallowRef<HTMLElement>(); -const headerEl = $shallowRef<HTMLElement>(); -const footerEl = $shallowRef<HTMLElement>(); -const bodyEl = $shallowRef<HTMLElement>(); +const rootEl = shallowRef<HTMLElement>(); +const headerEl = shallowRef<HTMLElement>(); +const footerEl = shallowRef<HTMLElement>(); +const bodyEl = shallowRef<HTMLElement>(); -let headerHeight = $ref<string | undefined>(); -let childStickyTop = $ref(0); +const headerHeight = ref<string | undefined>(); +const childStickyTop = ref(0); const parentStickyTop = inject<Ref<number>>(CURRENT_STICKY_TOP, ref(0)); -provide(CURRENT_STICKY_TOP, $$(childStickyTop)); +provide(CURRENT_STICKY_TOP, childStickyTop); -let footerHeight = $ref<string | undefined>(); -let childStickyBottom = $ref(0); +const footerHeight = ref<string | undefined>(); +const childStickyBottom = ref(0); const parentStickyBottom = inject<Ref<number>>(CURRENT_STICKY_BOTTOM, ref(0)); -provide(CURRENT_STICKY_BOTTOM, $$(childStickyBottom)); +provide(CURRENT_STICKY_BOTTOM, childStickyBottom); const calc = () => { // コンポーネントが表示されてないけどKeepAliveで残ってる場合などは null になる - if (headerEl != null) { - childStickyTop = parentStickyTop.value + headerEl.offsetHeight; - headerHeight = headerEl.offsetHeight.toString(); + if (headerEl.value != null) { + childStickyTop.value = parentStickyTop.value + headerEl.value.offsetHeight; + headerHeight.value = headerEl.value.offsetHeight.toString(); } // コンポーネントが表示されてないけどKeepAliveで残ってる場合などは null になる - if (footerEl != null) { - childStickyBottom = parentStickyBottom.value + footerEl.offsetHeight; - footerHeight = footerEl.offsetHeight.toString(); + if (footerEl.value != null) { + childStickyBottom.value = parentStickyBottom.value + footerEl.value.offsetHeight; + footerHeight.value = footerEl.value.offsetHeight.toString(); } }; @@ -62,28 +62,28 @@ onMounted(() => { watch([parentStickyTop, parentStickyBottom], calc); - watch($$(childStickyTop), () => { - bodyEl.style.setProperty('--stickyTop', `${childStickyTop}px`); + watch(childStickyTop, () => { + bodyEl.value.style.setProperty('--stickyTop', `${childStickyTop.value}px`); }, { immediate: true, }); - watch($$(childStickyBottom), () => { - bodyEl.style.setProperty('--stickyBottom', `${childStickyBottom}px`); + watch(childStickyBottom, () => { + bodyEl.value.style.setProperty('--stickyBottom', `${childStickyBottom.value}px`); }, { immediate: true, }); - headerEl.style.position = 'sticky'; - headerEl.style.top = 'var(--stickyTop, 0)'; - headerEl.style.zIndex = '1000'; + headerEl.value.style.position = 'sticky'; + headerEl.value.style.top = 'var(--stickyTop, 0)'; + headerEl.value.style.zIndex = '1000'; - footerEl.style.position = 'sticky'; - footerEl.style.bottom = 'var(--stickyBottom, 0)'; - footerEl.style.zIndex = '1000'; + footerEl.value.style.position = 'sticky'; + footerEl.value.style.bottom = 'var(--stickyBottom, 0)'; + footerEl.value.style.zIndex = '1000'; - observer.observe(headerEl); - observer.observe(footerEl); + observer.observe(headerEl.value); + observer.observe(footerEl.value); }); onUnmounted(() => { @@ -91,6 +91,6 @@ onUnmounted(() => { }); defineExpose({ - rootEl: $$(rootEl), + rootEl: rootEl, }); </script> diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue index f08d538fc0..e11db9dc31 100644 --- a/packages/frontend/src/components/global/MkTime.vue +++ b/packages/frontend/src/components/global/MkTime.vue @@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import isChromatic from 'chromatic/isChromatic'; -import { onMounted, onUnmounted } from 'vue'; +import { onMounted, onUnmounted, ref, computed } from 'vue'; import { i18n } from '@/i18n.js'; import { dateTimeFormat } from '@/scripts/intl-const.js'; @@ -28,35 +28,48 @@ const props = withDefaults(defineProps<{ mode: 'relative', }); -const _time = props.time == null ? NaN : - typeof props.time === 'number' ? props.time : - (props.time instanceof Date ? props.time : new Date(props.time)).getTime(); +function getDateSafe(n: Date | string | number) { + try { + if (n instanceof Date) { + return n; + } + return new Date(n); + } catch (err) { + return { + getTime: () => NaN, + }; + } +} + +// eslint-disable-next-line vue/no-setup-props-destructure +const _time = props.time == null ? NaN : getDateSafe(props.time).getTime(); const invalid = Number.isNaN(_time); const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid; -let now = $ref((props.origin ?? new Date()).getTime()); -const ago = $computed(() => (now - _time) / 1000/*ms*/); +// eslint-disable-next-line vue/no-setup-props-destructure +const now = ref((props.origin ?? new Date()).getTime()); +const ago = computed(() => (now.value - _time) / 1000/*ms*/); -const relative = $computed<string>(() => { +const relative = computed<string>(() => { if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない if (invalid) return i18n.ts._ago.invalid; return ( - ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) : - ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) : - ago >= 604800 ? i18n.t('_ago.weeksAgo', { n: Math.round(ago / 604800).toString() }) : - ago >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago / 86400).toString() }) : - ago >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago / 3600).toString() }) : - ago >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) : - ago >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) : - ago >= -3 ? i18n.ts._ago.justNow : - ago < -31536000 ? i18n.t('_timeIn.years', { n: Math.round(-ago / 31536000).toString() }) : - ago < -2592000 ? i18n.t('_timeIn.months', { n: Math.round(-ago / 2592000).toString() }) : - ago < -604800 ? i18n.t('_timeIn.weeks', { n: Math.round(-ago / 604800).toString() }) : - ago < -86400 ? i18n.t('_timeIn.days', { n: Math.round(-ago / 86400).toString() }) : - ago < -3600 ? i18n.t('_timeIn.hours', { n: Math.round(-ago / 3600).toString() }) : - ago < -60 ? i18n.t('_timeIn.minutes', { n: (~~(-ago / 60)).toString() }) : - i18n.t('_timeIn.seconds', { n: (~~(-ago % 60)).toString() }) + ago.value >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago.value / 31536000).toString() }) : + ago.value >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago.value / 2592000).toString() }) : + ago.value >= 604800 ? i18n.t('_ago.weeksAgo', { n: Math.round(ago.value / 604800).toString() }) : + ago.value >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago.value / 86400).toString() }) : + ago.value >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago.value / 3600).toString() }) : + ago.value >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago.value / 60)).toString() }) : + ago.value >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago.value % 60)).toString() }) : + ago.value >= -3 ? i18n.ts._ago.justNow : + ago.value < -31536000 ? i18n.t('_timeIn.years', { n: Math.round(-ago.value / 31536000).toString() }) : + ago.value < -2592000 ? i18n.t('_timeIn.months', { n: Math.round(-ago.value / 2592000).toString() }) : + ago.value < -604800 ? i18n.t('_timeIn.weeks', { n: Math.round(-ago.value / 604800).toString() }) : + ago.value < -86400 ? i18n.t('_timeIn.days', { n: Math.round(-ago.value / 86400).toString() }) : + ago.value < -3600 ? i18n.t('_timeIn.hours', { n: Math.round(-ago.value / 3600).toString() }) : + ago.value < -60 ? i18n.t('_timeIn.minutes', { n: (~~(-ago.value / 60)).toString() }) : + i18n.t('_timeIn.seconds', { n: (~~(-ago.value % 60)).toString() }) ); }); @@ -64,8 +77,8 @@ let tickId: number; let currentInterval: number; function tick() { - now = (new Date()).getTime(); - const nextInterval = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000; + now.value = (new Date()).getTime(); + const nextInterval = ago.value < 60 ? 10000 : ago.value < 3600 ? 60000 : 180000; if (currentInterval !== nextInterval) { if (tickId) window.clearInterval(tickId); diff --git a/packages/frontend/src/components/global/MkUrl.vue b/packages/frontend/src/components/global/MkUrl.vue index d29c720278..667a113432 100644 --- a/packages/frontend/src/components/global/MkUrl.vue +++ b/packages/frontend/src/components/global/MkUrl.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <component - :is="self ? 'MkA' : 'a'" ref="el" :class="$style.root" class="_link" :[attr]="self ? props.url.substring(local.length) : props.url" :rel="rel" :target="target" + :is="self ? 'MkA' : 'a'" ref="el" :class="$style.root" class="_link" :[attr]="self ? props.url.substring(local.length) : props.url" :rel="rel ?? 'nofollow noopener'" :target="target" @contextmenu.stop="() => {}" > <template v-if="!self"> diff --git a/packages/frontend/src/components/global/MkUserName.stories.impl.ts b/packages/frontend/src/components/global/MkUserName.stories.impl.ts index 8c24a4819f..01455e492d 100644 --- a/packages/frontend/src/components/global/MkUserName.stories.impl.ts +++ b/packages/frontend/src/components/global/MkUserName.stories.impl.ts @@ -5,7 +5,6 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { expect } from '@storybook/jest'; -import { userEvent, within } from '@storybook/testing-library'; import { StoryObj } from '@storybook/vue3'; import { userDetailed } from '../../../.storybook/fakes'; import MkUserName from './MkUserName.vue'; diff --git a/packages/frontend/src/components/global/RouterView.vue b/packages/frontend/src/components/global/RouterView.vue index 99f42f4fcb..9da8f8c379 100644 --- a/packages/frontend/src/components/global/RouterView.vue +++ b/packages/frontend/src/components/global/RouterView.vue @@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { inject, onBeforeUnmount, provide } from 'vue'; +import { inject, onBeforeUnmount, provide, shallowRef, ref } from 'vue'; import { Resolved, Router } from '@/nirax'; import { defaultStore } from '@/store.js'; @@ -46,16 +46,16 @@ function resolveNested(current: Resolved, d = 0): Resolved | null { } const current = resolveNested(router.current)!; -let currentPageComponent = $shallowRef(current.route.component); -let currentPageProps = $ref(current.props); -let key = $ref(current.route.path + JSON.stringify(Object.fromEntries(current.props))); +const currentPageComponent = shallowRef(current.route.component); +const currentPageProps = ref(current.props); +const key = ref(current.route.path + JSON.stringify(Object.fromEntries(current.props))); function onChange({ resolved, key: newKey }) { const current = resolveNested(resolved); if (current == null) return; - currentPageComponent = current.route.component; - currentPageProps = current.props; - key = current.route.path + JSON.stringify(Object.fromEntries(current.props)); + currentPageComponent.value = current.route.component; + currentPageProps.value = current.props; + key.value = current.route.path + JSON.stringify(Object.fromEntries(current.props)); } router.addListener('change', onChange); diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index c740d181f9..a3e13c3a50 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -25,6 +25,7 @@ import MkPageHeader from './global/MkPageHeader.vue'; import MkSpacer from './global/MkSpacer.vue'; import MkFooterSpacer from './global/MkFooterSpacer.vue'; import MkStickyContainer from './global/MkStickyContainer.vue'; +import MkLazy from './global/MkLazy.vue'; export default function(app: App) { for (const [key, value] of Object.entries(components)) { @@ -53,6 +54,7 @@ export const components = { MkSpacer: MkSpacer, MkFooterSpacer: MkFooterSpacer, MkStickyContainer: MkStickyContainer, + MkLazy: MkLazy, }; declare module '@vue/runtime-core' { @@ -77,5 +79,6 @@ declare module '@vue/runtime-core' { MkSpacer: typeof MkSpacer; MkFooterSpacer: typeof MkFooterSpacer; MkStickyContainer: typeof MkStickyContainer; + MkLazy: typeof MkLazy; } } diff --git a/packages/frontend/src/components/page/page.text.vue b/packages/frontend/src/components/page/page.text.vue index 6aa2c1c0b7..892522d4b5 100644 --- a/packages/frontend/src/components/page/page.text.vue +++ b/packages/frontend/src/components/page/page.text.vue @@ -16,7 +16,6 @@ import * as mfm from '@sharkey/sfm-js'; import * as Misskey from 'misskey-js'; import { TextBlock } from './block.type'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js'; -import { $i } from '@/account.js'; const MkUrlPreview = defineAsyncComponent(() => import('@/components/MkUrlPreview.vue')); diff --git a/packages/frontend/src/components/page/page.vue b/packages/frontend/src/components/page/page.vue index ab37ca69ad..94ca7bdf04 100644 --- a/packages/frontend/src/components/page/page.vue +++ b/packages/frontend/src/components/page/page.vue @@ -10,7 +10,6 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { onMounted, nextTick } from 'vue'; import * as Misskey from 'misskey-js'; import XBlock from './page.block.vue'; |