diff options
Diffstat (limited to 'packages/frontend')
38 files changed, 1437 insertions, 147 deletions
diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index 93916ccf2f..31c125d3ae 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -2,11 +2,11 @@ import { defineAsyncComponent, reactive } from 'vue'; import * as misskey from 'misskey-js'; import { showSuspendedDialog } from './scripts/show-suspended-dialog'; import { i18n } from './i18n'; +import { miLocalStorage } from './local-storage'; import { del, get, set } from '@/scripts/idb-proxy'; import { apiUrl } from '@/config'; import { waiting, api, popup, popupMenu, success, alert } from '@/os'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; -import { miLocalStorage } from './local-storage'; // TODO: 他のタブと永続化されたstateを同期 @@ -20,6 +20,11 @@ export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : n export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator); export const iAmAdmin = $i != null && $i.isAdmin; +export let notesCount = $i == null ? 0 : $i.notesCount; +export function incNotesCount() { + notesCount++; +} + export async function signout() { waiting(); miLocalStorage.removeItem('account'); diff --git a/packages/frontend/src/components/MkAchievements.vue b/packages/frontend/src/components/MkAchievements.vue new file mode 100644 index 0000000000..64fea96354 --- /dev/null +++ b/packages/frontend/src/components/MkAchievements.vue @@ -0,0 +1,224 @@ +<template> +<div> + <div v-if="achievements" :class="$style.root"> + <div v-for="achievement in achievements" :key="achievement" :class="$style.achievement" class="_panel"> + <div :class="$style.icon"> + <div :class="[$style.iconFrame, $style['iconFrame_' + ACHIEVEMENT_BADGES[achievement.name].frame]]"> + <div :class="[$style.iconInner]" :style="{ background: ACHIEVEMENT_BADGES[achievement.name].bg }"> + <img :class="$style.iconImg" :src="ACHIEVEMENT_BADGES[achievement.name].img"> + </div> + </div> + </div> + <div :class="$style.body"> + <div :class="$style.header"> + <span :class="$style.title">{{ i18n.ts._achievements._types['_' + achievement.name].title }}</span> + <span :class="$style.time"> + <time v-tooltip="new Date(achievement.unlockedAt).toLocaleString()">{{ new Date(achievement.unlockedAt).getFullYear() }}/{{ new Date(achievement.unlockedAt).getMonth() + 1 }}/{{ new Date(achievement.unlockedAt).getDate() }}</time> + </span> + </div> + <div :class="$style.description">{{ i18n.ts._achievements._types['_' + achievement.name].description }}</div> + <div v-if="i18n.ts._achievements._types['_' + achievement.name].flavor" :class="$style.flavor">{{ i18n.ts._achievements._types['_' + achievement.name].flavor }}</div> + </div> + </div> + <template v-if="withLocked"> + <div v-for="achievement in lockedAchievements" :key="achievement" :class="[$style.achievement, $style.locked]" class="_panel" @click="achievement === 'clickedClickHere' ? clickHere() : () => {}"> + <div :class="$style.icon"> + </div> + <div :class="$style.body"> + <div :class="$style.header"> + <span :class="$style.title">???</span> + </div> + <div :class="$style.description">???</div> + </div> + </div> + </template> + </div> + <div v-else> + <MkLoading/> + </div> +</div> +</template> + +<script lang="ts" setup> +import * as misskey from 'misskey-js'; +import { onMounted } from 'vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/scripts/achievements'; + +const props = withDefaults(defineProps<{ + user: misskey.entities.User; + withLocked: boolean; +}>(), { + withLocked: true, +}); + +let achievements = $ref(); +const lockedAchievements = $computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements ?? []).some(a => a.name === x))); + +function fetch() { + os.api('users/achievements', { userId: props.user.id }).then(res => { + achievements = []; + for (const t of ACHIEVEMENT_TYPES) { + const a = res.find(x => x.name === t); + if (a) achievements.push(a); + } + //achievements = res.sort((a, b) => b.unlockedAt - a.unlockedAt); + }); +} + +function clickHere() { + claimAchievement('clickedClickHere'); + fetch(); +} + +onMounted(() => { + fetch(); +}); +</script> + +<style lang="scss" module> +.root { + display: grid; + grid-template-columns: repeat(auto-fill, min(380px, 100%)); + grid-gap: 12px; + place-content: center; +} + +.achievement { + display: flex; + padding: 16px; + + &.locked { + opacity: 0.5; + } +} + +.icon { + flex-shrink: 0; + margin-right: 12px; +} + +@keyframes shine { + 0% { translate: -30px; } + 100% { translate: -130px; } +} + +.iconFrame { + width: 58px; + height: 58px; + padding: 6px; + border-radius: 100%; + box-sizing: border-box; + pointer-events: none; + user-select: none; + filter: drop-shadow(0px 2px 2px #00000044); + box-shadow: 0 1px 0px #ffffff88 inset; + overflow: clip; +} +.iconFrame_bronze { + background: linear-gradient(0deg, #703827, #d37566); + + > .iconInner { + background: linear-gradient(0deg, #d37566, #703827); + } +} +.iconFrame_silver { + background: linear-gradient(0deg, #7c7c7c, #e1e1e1); + + > .iconInner { + background: linear-gradient(0deg, #e1e1e1, #7c7c7c); + } +} +.iconFrame_gold { + background: linear-gradient(0deg, rgba(255,182,85,1) 0%, rgba(233,133,0,1) 49%, rgba(255,243,93,1) 51%, rgba(255,187,25,1) 100%); + + > .iconInner { + background: linear-gradient(0deg, #ffee20, #eb7018); + } + + &:before { + content: ""; + display: block; + position: absolute; + top: 30px; + width: 200px; + height: 8px; + rotate: -45deg; + translate: -30px; + background: #ffffff88; + animation: shine 2s infinite; + } +} +.iconFrame_platinum { + background: linear-gradient(0deg, rgba(154,154,154,1) 0%, rgba(226,226,226,1) 49%, rgba(255,255,255,1) 51%, rgba(195,195,195,1) 100%); + + > .iconInner { + background: linear-gradient(0deg, #e1e1e1, #7c7c7c); + } + + &:before { + content: ""; + display: block; + position: absolute; + top: 30px; + width: 200px; + height: 8px; + rotate: -45deg; + translate: -30px; + background: #ffffffee; + animation: shine 2s infinite; + } +} + +.iconInner { + position: relative; + width: 100%; + height: 100%; + border-radius: 100%; + box-shadow: 0 1px 0px #ffffff88 inset; +} + +.iconImg { + width: calc(100% - 12px); + height: calc(100% - 12px); + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + margin: auto; + filter: drop-shadow(0px 1px 2px #000000aa); +} + +.body { + flex: 1; + min-width: 0; +} + +.header { + margin-bottom: 8px; + display: flex; +} + +.title { + font-weight: bold; +} + +.time { + margin-left: auto; + font-size: 85%; + opacity: 0.7; +} + +.description { + font-size: 85%; +} + +.flavor { + opacity: 0.7; + transform: skewX(-15deg); + font-size: 85%; + margin-top: 8px; +} +</style> diff --git a/packages/frontend/src/components/MkAsUi.vue b/packages/frontend/src/components/MkAsUi.vue index cbdf924538..4f463d73d9 100644 --- a/packages/frontend/src/components/MkAsUi.vue +++ b/packages/frontend/src/components/MkAsUi.vue @@ -7,9 +7,9 @@ </div> <span v-else-if="c.type === 'text'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }">{{ c.text }}</span> <Mfm v-else-if="c.type === 'mfm'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }" :text="c.text"/> - <MkButton v-else-if="c.type === 'button'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" @click="c.onClick">{{ c.text }}</MkButton> - <div v-else-if="c.type === 'buttons'" class="_buttons"> - <MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton> + <MkButton v-else-if="c.type === 'button'" :primary="c.primary" :rounded="c.rounded" :disabled="c.disabled" :small="size === 'small'" inline @click="c.onClick">{{ c.text }}</MkButton> + <div v-else-if="c.type === 'buttons'" class="_buttons" :style="{ justifyContent: align }"> + <MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :disabled="button.disabled" inline :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton> </div> <MkSwitch v-else-if="c.type === 'switch'" :model-value="valueForSwitch" @update:model-value="onSwitchUpdate"> <template v-if="c.label" #label>{{ c.label }}</template> @@ -41,7 +41,7 @@ </MkFolder> <div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace', [$style.containerCenter]: c.align === 'center' }]" :style="{ backgroundColor: c.bgColor ?? null, color: c.fgColor ?? null, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }"> <template v-for="child in c.children" :key="child"> - <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/> + <MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size" :align="c.align"/> </template> </div> </div> @@ -62,8 +62,10 @@ const props = withDefaults(defineProps<{ component: AsUiComponent; components: Ref<AsUiComponent>[]; size: 'small' | 'medium' | 'large'; + align: 'left' | 'center' | 'right'; }>(), { size: 'medium', + align: 'left', }); const c = props.component; diff --git a/packages/frontend/src/components/MkClickerGame.vue b/packages/frontend/src/components/MkClickerGame.vue index 03736ac5e4..68e0f8185d 100644 --- a/packages/frontend/src/components/MkClickerGame.vue +++ b/packages/frontend/src/components/MkClickerGame.vue @@ -20,6 +20,7 @@ import * as os from '@/os'; import { useInterval } from '@/scripts/use-interval'; import * as game from '@/scripts/clicker-game'; import number from '@/filters/number'; +import { claimAchievement } from '@/scripts/achievements'; defineProps<{ }>(); @@ -30,14 +31,18 @@ let cps = $ref(0); let prevCookies = $ref(0); function onClick(ev: MouseEvent) { + const x = ev.clientX; + const y = ev.clientY; + os.popup(MkPlusOneEffect, { x, y }, {}, 'end'); + saveData.value!.cookies++; saveData.value!.totalCookies++; saveData.value!.totalHandmadeCookies++; saveData.value!.clicked++; - const x = ev.clientX; - const y = ev.clientY; - os.popup(MkPlusOneEffect, { x, y }, {}, 'end'); + if (cookies.value === 1) { + claimAchievement('cookieClicked'); + } } useInterval(() => { diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 82653ca0b4..156013b9aa 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -33,6 +33,7 @@ import * as Misskey from 'misskey-js'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; +import { claimAchievement } from '@/scripts/achievements'; const props = withDefaults(defineProps<{ folder: Misskey.entities.DriveFolder; @@ -159,9 +160,11 @@ function onDrop(ev: DragEvent) { }).then(() => { // noop }).catch(err => { - switch (err) { - case 'detected-circular-definition': + switch (err.code) { + case 'RECURSIVE_NESTING': + claimAchievement('driveFolderCircularReference'); os.alert({ + type: 'error', title: i18n.ts.unableToProcess, text: i18n.ts.circularReferenceFolder, }); diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue index 112a64f52d..af7175e5cd 100644 --- a/packages/frontend/src/components/MkDrive.vue +++ b/packages/frontend/src/components/MkDrive.vue @@ -99,6 +99,7 @@ import { stream } from '@/stream'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; import { uploadFile, uploads } from '@/scripts/upload'; +import { claimAchievement } from '@/scripts/achievements'; const props = withDefaults(defineProps<{ initialFolder?: Misskey.entities.DriveFolder; @@ -268,9 +269,11 @@ function onDrop(ev: DragEvent): any { }).then(() => { // noop }).catch(err => { - switch (err) { - case 'detected-circular-definition': + switch (err.code) { + case 'RECURSIVE_NESTING': + claimAchievement('driveFolderCircularReference'); os.alert({ + type: 'error', title: i18n.ts.unableToProcess, text: i18n.ts.circularReferenceFolder, }); diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index ee256d9263..de8db54bfa 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -35,6 +35,8 @@ import * as Misskey from 'misskey-js'; import * as os from '@/os'; import { stream } from '@/stream'; import { i18n } from '@/i18n'; +import { claimAchievement } from '@/scripts/achievements'; +import { $i } from '@/account'; const props = withDefaults(defineProps<{ user: Misskey.entities.UserDetailed, @@ -90,6 +92,21 @@ async function onClick() { userId: props.user.id, }); hasPendingFollowRequestFromYou = true; + + claimAchievement('following1'); + + if ($i.followingCount >= 10) { + claimAchievement('following10'); + } + if ($i.followingCount >= 50) { + claimAchievement('following50'); + } + if ($i.followingCount >= 100) { + claimAchievement('following100'); + } + if ($i.followingCount >= 300) { + claimAchievement('following300'); + } } } } catch (err) { diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index 9912faffe8..c0638c0feb 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -13,7 +13,7 @@ :href="image.url" :title="image.name" > - <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment" :title="image.comment" :cover="false"/> + <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :cover="false"/> <div v-if="image.type === 'image/gif'" class="gif">GIF</div> </a> <button v-tooltip="$ts.hide" class="_button hide" @click="hide = true"><i class="ti ti-eye-off"></i></button> diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index c6f8612182..f263ae0ce9 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -45,7 +45,8 @@ onMounted(() => { src: media.url, w: media.properties.width, h: media.properties.height, - alt: media.name, + alt: media.comment || media.name, + comment: media.comment || media.name, }; if (media.properties.orientation != null && media.properties.orientation >= 5) { [item.w, item.h] = [item.h, item.w]; @@ -69,6 +70,7 @@ onMounted(() => { }, imageClickAction: 'close', tapAction: 'toggle-controls', + bgOpacity: 1, pswpModule: PhotoSwipe, }); @@ -88,9 +90,28 @@ onMounted(() => { [itemData.w, itemData.h] = [itemData.h, itemData.w]; } itemData.msrc = file.thumbnailUrl; + itemData.alt = file.comment || file.name; + itemData.comment = file.comment || file.name; itemData.thumbCropped = true; }); + lightbox.on('uiRegister', () => { + lightbox.pswp.ui.registerElement({ + name: 'altText', + className: 'pwsp__alt-text-container', + appendTo: 'wrapper', + onInit: (el, pwsp) => { + let textBox = document.createElement('p'); + textBox.className = 'pwsp__alt-text _acrylic'; + el.appendChild(textBox); + + pwsp.on('change', (a) => { + textBox.textContent = pwsp.currSlide.data.comment; + }); + }, + }); + }); + lightbox.init(); }); @@ -185,5 +206,36 @@ const previewable = (file: misskey.entities.DriveFile): boolean => { // なぜか機能しない //z-index: v-bind(pswpZIndex); z-index: 2000000; + --pswp-bg: var(--modalBg); +} + +.pswp__bg { + background: var(--modalBg); + backdrop-filter: var(--modalBgFilter); +} + +.pwsp__alt-text-container { + display: flex; + flex-direction: row; + align-items: center; + + position: absolute; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + + width: 75%; + max-width: 800px; +} + +.pwsp__alt-text { + color: var(--fg); + margin: 0 auto; + text-align: center; + padding: var(--margin); + border-radius: var(--radius); + max-height: 8em; + overflow-y: auto; + text-shadow: var(--bg) 0 0 10px, var(--bg) 0 0 3px, var(--bg) 0 0 3px; } </style> diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 9b2501a2ed..1f6a2883d7 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -143,6 +143,7 @@ import { getNoteMenu } from '@/scripts/get-note-menu'; import { useNoteCapture } from '@/scripts/use-note-capture'; import { deepClone } from '@/scripts/clone'; import { useTooltip } from '@/scripts/use-tooltip'; +import { claimAchievement } from '@/scripts/achievements'; const props = defineProps<{ note: misskey.entities.Note; @@ -268,6 +269,9 @@ function react(viaKeyboard = false): void { noteId: appearNote.id, reaction: reaction, }); + if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { + claimAchievement('reactWithoutRead'); + } }, () => { focus(); }); diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 56061e0e6f..48ace56d9c 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -159,6 +159,7 @@ import { getNoteMenu } from '@/scripts/get-note-menu'; import { useNoteCapture } from '@/scripts/use-note-capture'; import { deepClone } from '@/scripts/clone'; import { useTooltip } from '@/scripts/use-tooltip'; +import { claimAchievement } from '@/scripts/achievements'; const props = defineProps<{ note: misskey.entities.Note; @@ -279,6 +280,9 @@ function react(viaKeyboard = false): void { noteId: appearNote.id, reaction: reaction, }); + if (appearNote.text && appearNote.text.length > 100 && (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 3)) { + claimAchievement('reactWithoutRead'); + } }, () => { focus(); }); diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 4f82579917..e992495a78 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -2,6 +2,7 @@ <div ref="elRef" :class="$style.root"> <div v-once :class="$style.head"> <MkAvatar v-if="notification.type === 'pollEnded'" :class="$style.icon" :user="notification.note.user" link preview/> + <MkAvatar v-else-if="notification.type === 'achievementEarned'" :class="$style.icon" :user="$i" link preview/> <MkAvatar v-else-if="notification.user" :class="$style.icon" :user="notification.user" link preview/> <img v-else-if="notification.icon" :class="$style.icon" :src="notification.icon" alt=""/> <div :class="[$style.subIcon, $style['t_' + notification.type]]"> @@ -14,6 +15,7 @@ <i v-else-if="notification.type === 'mention'" class="ti ti-at"></i> <i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i> <i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i> + <i v-else-if="notification.type === 'achievementEarned'" class="ti ti-military-award"></i> <!-- notification.reaction が null になることはまずないが、ここでoptional chaining使うと一部ブラウザで刺さるので念の為 --> <MkReactionIcon v-else-if="notification.type === 'reaction'" @@ -28,6 +30,7 @@ <div :class="$style.tail"> <header :class="$style.header"> <span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span> + <span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</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> <span v-else>{{ notification.header }}</span> <MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/> @@ -57,6 +60,9 @@ <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :author="notification.note.user"/> <i class="ti ti-quote" :class="$style.quote"></i> </MkA> + <MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements"> + {{ i18n.ts._achievements._types['_' + notification.achievement].title }} + </MkA> <span v-else-if="notification.type === 'follow'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span> <span v-else-if="notification.type === 'followRequestAccepted'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span> <span v-else-if="notification.type === 'receiveFollowRequest'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ i18n.ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ i18n.ts.reject }}</button></div></span> @@ -82,6 +88,7 @@ import { i18n } from '@/i18n'; import * as os from '@/os'; import { stream } from '@/stream'; import { useTooltip } from '@/scripts/use-tooltip'; +import { $i } from '@/account'; const props = withDefaults(defineProps<{ notification: misskey.entities.Notification; @@ -240,6 +247,12 @@ useTooltip(reactionRef, (showing) => { pointer-events: none; } +.t_achievementEarned { + padding: 3px; + background: #88a6b7; + pointer-events: none; +} + .tail { flex: 1; min-width: 0; @@ -267,9 +280,9 @@ useTooltip(reactionRef, (showing) => { } .text { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + display: flex; + width: 100%; + overflow: clip; } .quote { diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index ab5dff8db5..f5ae7bcee4 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -10,7 +10,7 @@ <template #default="{ items: notifications }"> <MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :no-gap="true"> <XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/> - <XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/> + <XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="false" class="_panel notification"/> </MkDateSeparatedList> </template> </MkPagination> diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue index 25b9da2d0b..d12aafd06d 100644 --- a/packages/frontend/src/components/MkPageWindow.vue +++ b/packages/frontend/src/components/MkPageWindow.vue @@ -24,7 +24,7 @@ </template> <script lang="ts" setup> -import { ComputedRef, inject, provide } from 'vue'; +import { ComputedRef, inject, onMounted, onUnmounted, provide } from 'vue'; import RouterView from '@/components/global/RouterView.vue'; import MkWindow from '@/components/MkWindow.vue'; import { popout as _popout } from '@/scripts/popout'; @@ -35,6 +35,8 @@ import { mainRouter, routes } from '@/router'; import { Router } from '@/nirax'; import { i18n } from '@/i18n'; import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; +import { openingWindowsCount } from '@/os'; +import { claimAchievement } from '@/scripts/achievements'; const props = defineProps<{ initialPath: string; @@ -128,6 +130,17 @@ function popout() { windowEl.close(); } +onMounted(() => { + openingWindowsCount.value++; + if (openingWindowsCount.value >= 3) { + claimAchievement('open3windows'); + } +}); + +onUnmounted(() => { + openingWindowsCount.value--; +}); + defineExpose({ close, }); diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 6822caf4f4..c7e7e85b2e 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -93,11 +93,12 @@ import { defaultStore, notePostInterruptors, postFormActions } from '@/store'; import MkInfo from '@/components/MkInfo.vue'; import { i18n } from '@/i18n'; import { instance } from '@/instance'; -import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account'; +import { $i, notesCount, incNotesCount, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account'; import { uploadFile } from '@/scripts/upload'; import { deepClone } from '@/scripts/clone'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { miLocalStorage } from '@/local-storage'; +import { claimAchievement } from '@/scripts/achievements'; const modal = inject('modal'); @@ -627,6 +628,34 @@ async function post(ev?: MouseEvent) { } posting = false; postAccount = null; + + incNotesCount(); + if (notesCount === 1) { + claimAchievement('notes1'); + } + + const text = postData.text?.toLowerCase() ?? ''; + if ((text.includes('love') || text.includes('❤')) && text.includes('misskey')) { + claimAchievement('iLoveMisskey'); + } + if (text.includes('Efrlqw8ytg4'.toLowerCase()) || text.includes('XVCwzwxdHuA'.toLowerCase())) { + claimAchievement('brainDiver'); + } + + if (props.renote && (props.renote.userId === $i.id) && text.length > 0) { + claimAchievement('selfQuote'); + } + + const date = new Date(); + const h = date.getHours(); + const m = date.getMinutes(); + const s = date.getSeconds(); + if (h >= 0 && h <= 3) { + claimAchievement('postedAtLateNight'); + } + if (m === 0 && s === 0) { + claimAchievement('postedAt0min0sec'); + } }); }).catch(err => { posting = false; diff --git a/packages/frontend/src/components/MkReactedUsersDialog.vue b/packages/frontend/src/components/MkReactedUsersDialog.vue new file mode 100644 index 0000000000..2a8dffc014 --- /dev/null +++ b/packages/frontend/src/components/MkReactedUsersDialog.vue @@ -0,0 +1,92 @@ +<template> +<MkModalWindow + ref="dialog" + :width="400" + :height="450" + @close="dialog.close()" + @closed="emit('closed')" +> + <template #header>{{ i18n.ts.reactions }}</template> + + <MkSpacer :margin-min="20" :margin-max="28"> + <div v-if="note" class="_gaps"> + <div :class="$style.tabs"> + <button v-for="reaction in reactions" :key="reaction" :class="[$style.tab, { [$style.tabActive]: tab === reaction }]" class="_button" @click="tab = reaction"> + <MkReactionIcon :reaction="reaction"/> + <span style="margin-left: 4px;">{{ note.reactions[reaction] }}</span> + </button> + </div> + <MkA v-for="user in users" :key="user.id" :to="userPage(user)"> + <MkUserCardMini :user="user" :with-chart="false"/> + </MkA> + </div> + <div v-else> + <MkLoading/> + </div> + </MkSpacer> +</MkModalWindow> +</template> + +<script lang="ts" setup> +import { onMounted, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkReactionIcon from '@/components/MkReactionIcon.vue'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import { userPage } from '@/filters/user'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; + +const emit = defineEmits<{ + (ev: 'closed'): void, +}>(); + +const props = defineProps<{ + noteId: misskey.entities.Note['id']; +}>(); + +const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); + +let note = $ref<misskey.entities.Note>(); +let tab = $ref<string>(); +let reactions = $ref<string[]>(); +let users = $ref(); + +watch($$(tab), async () => { + const res = await os.api('notes/reactions', { + noteId: props.noteId, + type: tab, + limit: 30, + }); + + users = res.map(x => x.user); +}); + +onMounted(() => { + os.api('notes/show', { + noteId: props.noteId, + }).then((res) => { + reactions = Object.keys(res.reactions); + tab = reactions[0]; + note = res; + }); +}); +</script> + +<style lang="scss" module> +.tabs { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.tab { + padding: 4px 6px; + border: solid 1px var(--divider); + border-radius: 6px; +} + +.tabActive { + border-color: var(--accent); +} +</style> diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index e90dd7ea69..ec4042d18c 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -20,6 +20,7 @@ import * as os from '@/os'; import { useTooltip } from '@/scripts/use-tooltip'; import { $i } from '@/account'; import MkReactionEffect from '@/components/MkReactionEffect.vue'; +import { claimAchievement } from '@/scripts/achievements'; const props = defineProps<{ reaction: string; @@ -52,6 +53,9 @@ const toggleReaction = () => { noteId: props.note.id, reaction: props.reaction, }); + if (props.note.text && props.note.text.length > 100 && (Date.now() - new Date(props.note.createdAt).getTime() < 1000 * 3)) { + claimAchievement('reactWithoutRead'); + } } }; diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue index ec199ad277..457504e6ca 100644 --- a/packages/frontend/src/components/MkUserCardMini.vue +++ b/packages/frontend/src/components/MkUserCardMini.vue @@ -11,20 +11,28 @@ <script lang="ts" setup> import * as misskey from 'misskey-js'; +import { onMounted } from 'vue'; import MkMiniChart from '@/components/MkMiniChart.vue'; import * as os from '@/os'; import { acct } from '@/filters/user'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ user: misskey.entities.User; -}>(); + withChart: boolean; +}>(), { + withChart: true, +}); let chartValues = $ref<number[] | null>(null); -os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16 + 1, span: 'day' }).then(res => { - // 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く - res.inc.splice(0, 1); - chartValues = res.inc; +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; + }); + } }); </script> diff --git a/packages/frontend/src/components/global/MkEmoji.vue b/packages/frontend/src/components/global/MkEmoji.vue index bc88cf3be4..b7dd0296cd 100644 --- a/packages/frontend/src/components/global/MkEmoji.vue +++ b/packages/frontend/src/components/global/MkEmoji.vue @@ -1,5 +1,6 @@ <template> -<img v-if="isCustom" :class="[$style.root, $style.custom, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" :src="url" :alt="alt" :title="alt" decoding="async"/> +<span v-if="isCustom && errored">:{{ customEmojiName }}:</span> +<img v-else-if="isCustom" :class="[$style.root, $style.custom, { [$style.normal]: normal, [$style.noStyle]: noStyle }]" :src="url" :alt="alt" :title="alt" decoding="async" @error="errored = true"/> <img v-else-if="char && !useOsNativeEmojis" :class="$style.root" :src="url" :alt="alt" decoding="async" @pointerenter="computeTitle"/> <span v-else-if="char && useOsNativeEmojis" :alt="alt" @pointerenter="computeTitle">{{ char }}</span> <span v-else>{{ emoji }}</span> @@ -37,6 +38,7 @@ const url = computed(() => { } }); const alt = computed(() => isCustom.value ? `:${customEmojiName}:` : char.value); +let errored = $ref(false); // Searching from an array with 2000 items for every emoji felt like too energy-consuming, so I decided to do it lazily on pointerenter function computeTitle(event: PointerEvent): void { diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts index e10315e1ad..09fb7caf14 100644 --- a/packages/frontend/src/init.ts +++ b/packages/frontend/src/init.ts @@ -44,6 +44,7 @@ import { reactionPicker } from '@/scripts/reaction-picker'; import { getUrlWithoutLoginId } from '@/scripts/login-id'; import { getAccountFromId } from '@/scripts/get-account-from-id'; import { miLocalStorage } from './local-storage'; +import { claimAchievement, claimedAchievements } from './scripts/achievements'; (async () => { console.info(`Misskey v${version}`); @@ -345,6 +346,87 @@ import { miLocalStorage } from './local-storage'; }); } + const now = new Date(); + const m = now.getMonth() + 1; + const d = now.getDate(); + + if ($i.birthday) { + const bm = parseInt($i.birthday.split('-')[1]); + const bd = parseInt($i.birthday.split('-')[2]); + if (m === bm && d === bd) { + claimAchievement('loggedInOnBirthday'); + } + } + + if (m === 1 && d === 1) { + claimAchievement('loggedInOnNewYearsDay'); + } + + if ($i.loggedInDays >= 3) claimAchievement('login3'); + if ($i.loggedInDays >= 7) claimAchievement('login7'); + if ($i.loggedInDays >= 15) claimAchievement('login15'); + if ($i.loggedInDays >= 30) claimAchievement('login30'); + if ($i.loggedInDays >= 60) claimAchievement('login60'); + if ($i.loggedInDays >= 100) claimAchievement('login100'); + if ($i.loggedInDays >= 200) claimAchievement('login200'); + if ($i.loggedInDays >= 300) claimAchievement('login300'); + if ($i.loggedInDays >= 400) claimAchievement('login400'); + if ($i.loggedInDays >= 500) claimAchievement('login500'); + if ($i.loggedInDays >= 600) claimAchievement('login600'); + if ($i.loggedInDays >= 700) claimAchievement('login700'); + if ($i.loggedInDays >= 800) claimAchievement('login800'); + if ($i.loggedInDays >= 900) claimAchievement('login900'); + if ($i.loggedInDays >= 1000) claimAchievement('login1000'); + + if ($i.notesCount > 0) claimAchievement('notes1'); + if ($i.notesCount >= 10) claimAchievement('notes10'); + if ($i.notesCount >= 100) claimAchievement('notes100'); + if ($i.notesCount >= 500) claimAchievement('notes500'); + if ($i.notesCount >= 1000) claimAchievement('notes1000'); + if ($i.notesCount >= 5000) claimAchievement('notes5000'); + if ($i.notesCount >= 10000) claimAchievement('notes10000'); + if ($i.notesCount >= 20000) claimAchievement('notes20000'); + if ($i.notesCount >= 30000) claimAchievement('notes30000'); + if ($i.notesCount >= 40000) claimAchievement('notes40000'); + if ($i.notesCount >= 50000) claimAchievement('notes50000'); + if ($i.notesCount >= 60000) claimAchievement('notes60000'); + if ($i.notesCount >= 70000) claimAchievement('notes70000'); + if ($i.notesCount >= 80000) claimAchievement('notes80000'); + if ($i.notesCount >= 90000) claimAchievement('notes90000'); + if ($i.notesCount >= 100000) claimAchievement('notes100000'); + + if ($i.followersCount > 0) claimAchievement('followers1'); + if ($i.followersCount >= 10) claimAchievement('followers10'); + if ($i.followersCount >= 50) claimAchievement('followers50'); + if ($i.followersCount >= 100) claimAchievement('followers100'); + if ($i.followersCount >= 300) claimAchievement('followers300'); + if ($i.followersCount >= 500) claimAchievement('followers500'); + if ($i.followersCount >= 1000) claimAchievement('followers1000'); + + if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365) { + claimAchievement('passedSinceAccountCreated1'); + } + if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 2) { + claimAchievement('passedSinceAccountCreated2'); + } + if (Date.now() - new Date($i.createdAt).getTime() > 1000 * 60 * 60 * 24 * 365 * 3) { + claimAchievement('passedSinceAccountCreated3'); + } + + if (claimedAchievements.length >= 30) { + claimAchievement('collectAchievements30'); + } + + window.setInterval(() => { + if (Math.floor(Math.random() * 10000) === 0) { + claimAchievement('justPlainLucky'); + } + }, 1000 * 10); + + window.setTimeout(() => { + claimAchievement('client30min'); + }, 1000 * 60 * 30); + const lastUsed = miLocalStorage.getItem('lastUsed'); if (lastUsed) { const lastUsedDate = parseInt(lastUsed, 10); diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index 9ee78741dc..3d16a52e62 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -1,11 +1,11 @@ import { computed, ref, reactive } from 'vue'; import { $i } from './account'; +import { miLocalStorage } from './local-storage'; import { search } from '@/scripts/search'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { ui } from '@/config'; import { unisonReload } from '@/scripts/unison-reload'; -import { miLocalStorage } from './local-storage'; export const navbarItemDef = reactive({ notifications: { @@ -103,6 +103,12 @@ export const navbarItemDef = reactive({ icon: 'ti ti-device-tv', to: '/channels', }, + achievements: { + title: i18n.ts.achievements, + icon: 'ti ti-military-award', + show: computed(() => $i != null), + to: '/my/achievements', + }, ui: { title: i18n.ts.switchUi, icon: 'ti ti-devices', diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index b19443aa55..01f8244060 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -1,5 +1,7 @@ // TODO: なんでもかんでもos.tsに突っ込むのやめたいのでよしなに分割する +import { pendingApiRequestsCount, api, apiGet } from '@/scripts/api'; +export { pendingApiRequestsCount, api, apiGet }; import { Component, markRaw, Ref, ref, defineAsyncComponent } from 'vue'; import { EventEmitter } from 'eventemitter3'; import insertTextAtCursor from 'insert-text-at-cursor'; @@ -7,9 +9,16 @@ import * as Misskey from 'misskey-js'; import { i18n } from './i18n'; import MkPostFormDialog from '@/components/MkPostFormDialog.vue'; import MkWaitingDialog from '@/components/MkWaitingDialog.vue'; +import MkPageWindow from '@/components/MkPageWindow.vue'; +import MkToast from '@/components/MkToast.vue'; +import MkDialog from '@/components/MkDialog.vue'; +import MkEmojiPickerDialog from '@/components/MkEmojiPickerDialog.vue'; +import MkEmojiPickerWindow from '@/components/MkEmojiPickerWindow.vue'; +import MkPopupMenu from '@/components/MkPopupMenu.vue'; +import MkContextMenu from '@/components/MkContextMenu.vue'; import { MenuItem } from '@/types/menu'; -import { pendingApiRequestsCount, api, apiGet } from '@/scripts/api'; -export { pendingApiRequestsCount, api, apiGet }; + +export const openingWindowsCount = ref(0); export const apiWithDialog = (( endpoint: string, @@ -124,7 +133,7 @@ export async function popup(component: Component, props: Record<string, any>, ev } export function pageWindow(path: string) { - popup(defineAsyncComponent(() => import('@/components/MkPageWindow.vue')), { + popup(MkPageWindow, { initialPath: path, }, {}, 'closed'); } @@ -136,7 +145,7 @@ export function modalPageWindow(path: string) { } export function toast(message: string) { - popup(defineAsyncComponent(() => import('@/components/MkToast.vue')), { + popup(MkToast, { message, }, {}, 'closed'); } @@ -147,7 +156,7 @@ export function alert(props: { text?: string | null; }): Promise<void> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), props, { + popup(MkDialog, props, { done: result => { resolve(); }, @@ -161,7 +170,7 @@ export function confirm(props: { text?: string | null; }): Promise<{ canceled: boolean }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + popup(MkDialog, { ...props, showCancelButton: true, }, { @@ -182,7 +191,7 @@ export function inputText(props: { canceled: false; result: string; }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + popup(MkDialog, { title: props.title, text: props.text, input: { @@ -207,7 +216,7 @@ export function inputNumber(props: { canceled: false; result: number; }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + popup(MkDialog, { title: props.title, text: props.text, input: { @@ -232,7 +241,7 @@ export function inputDate(props: { canceled: false; result: Date; }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + popup(MkDialog, { title: props.title, text: props.text, input: { @@ -269,7 +278,7 @@ export function select<C = any>(props: { canceled: false; result: C; }> { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/MkDialog.vue')), { + popup(MkDialog, { title: props.title, text: props.text, select: { @@ -291,7 +300,7 @@ export function success() { window.setTimeout(() => { showing.value = false; }, 1000); - popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + popup(MkWaitingDialog, { success: true, showing: showing, }, { @@ -303,7 +312,7 @@ export function success() { export function waiting() { return new Promise((resolve, reject) => { const showing = ref(true); - popup(defineAsyncComponent(() => import('@/components/MkWaitingDialog.vue')), { + popup(MkWaitingDialog, { success: false, showing: showing, }, { @@ -366,7 +375,7 @@ export async function selectDriveFolder(multiple: boolean) { export async function pickEmoji(src: HTMLElement | null, opts) { return new Promise((resolve, reject) => { - popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerDialog.vue')), { + popup(MkEmojiPickerDialog, { src, ...opts, }, { @@ -431,7 +440,7 @@ export async function openEmojiPicker(src?: HTMLElement, opts, initialTextarea: characterData: false, }); - openingEmojiPicker = await popup(defineAsyncComponent(() => import('@/components/MkEmojiPickerWindow.vue')), { + openingEmojiPicker = await popup(MkEmojiPickerWindow, { src, ...opts, }, { @@ -454,7 +463,7 @@ export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement }) { return new Promise((resolve, reject) => { let dispose; - popup(defineAsyncComponent(() => import('@/components/MkPopupMenu.vue')), { + popup(MkPopupMenu, { items, src, width: options?.width, @@ -478,7 +487,7 @@ export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent) ev.preventDefault(); return new Promise((resolve, reject) => { let dispose; - popup(defineAsyncComponent(() => import('@/components/MkContextMenu.vue')), { + popup(MkContextMenu, { items, ev, }, { diff --git a/packages/frontend/src/pages/achievements.vue b/packages/frontend/src/pages/achievements.vue new file mode 100644 index 0000000000..14b8520696 --- /dev/null +++ b/packages/frontend/src/pages/achievements.vue @@ -0,0 +1,54 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + <MkSpacer :content-max="1200"> + <MkAchievements :user="$i"/> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'; +import MkAchievements from '@/components/MkAchievements.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; +import { claimAchievement } from '@/scripts/achievements'; + +let timer: number | null; + +function viewAchievements3min() { + claimAchievement('viewAchievements3min'); +} + +onMounted(() => { + if (timer == null) timer = window.setTimeout(viewAchievements3min, 1000 * 60 * 3); +}); + +onUnmounted(() => { + if (timer != null) { + window.clearTimeout(timer); + timer = null; + } +}); + +onActivated(() => { + if (timer == null) timer = window.setTimeout(viewAchievements3min, 1000 * 60 * 3); +}); + +onDeactivated(() => { + if (timer != null) { + window.clearTimeout(timer); + timer = null; + } +}); + +definePageMetadata({ + title: i18n.ts.achievements, + icon: 'ti ti-military-award', +}); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 6e0c038982..ff8f8a356f 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -7,7 +7,7 @@ <MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton> <MkFolder> <template #label>{{ i18n.ts._role.baseRole }}</template> - <div class="_gaps"> + <div class="_gaps_s"> <MkFolder> <template #label>{{ i18n.ts._role._options.rateLimitFactor }}</template> <template #suffix>{{ Math.floor(policies.rateLimitFactor * 100) }}%</template> diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index 5ba226c10d..321477259b 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -16,6 +16,7 @@ <div class="_buttons"> <MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> <MkButton @click="show"><i class="ti ti-eye"></i> {{ i18n.ts.show }}</MkButton> + <MkButton v-if="flash" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> </div> </div> </MkSpacer> @@ -94,6 +95,85 @@ Ui:render([ ]) `; +const PRESET_SHUFFLE = `/// @ 0.12.2 +// 巻き戻し可能な文字シャッフルのプリセット + +let string = "ペペロンチーノ" +let length = string.len + +// 過去の結果を保存しておくやつ +var results = [] + +// どれだけ巻き戻しているか +var cursor = 0 + +@do() { + if (cursor != 0) { + results = results.slice(0 (cursor + 1)) + cursor = 0 + } + + let chars = [] + for (let i, length) { + let r = Math:rnd(0 (length - 1)) + chars.push(string.pick(r)) + } + let result = chars.join("") + + results.push(result) + + // UIを表示 + render(result) +} + +@back() { + cursor = cursor + 1 + let result = results[results.len - (cursor + 1)] + render(result) +} + +@forward() { + cursor = cursor - 1 + let result = results[results.len - (cursor + 1)] + render(result) +} + +@render(result) { + Ui:render([ + Ui:C:container({ + align: 'center' + children: [ + Ui:C:mfm({ text: result }) + Ui:C:buttons({ + buttons: [{ + text: "←" + disabled: !(results.len > 1 && (results.len - cursor) > 1) + onClick: back + } { + text: "→" + disabled: !(results.len > 1 && cursor > 0) + onClick: forward + } { + text: "引き直す" + onClick: do + }] + }) + Ui:C:postFormButton({ + text: "投稿する" + rounded: true + primary: true + form: { + text: \`{result}{Str:lf}{THIS_URL}\` + } + }) + ] + }) + ]) +} + +do() +`; + const PRESET_TIMELINE = `/// @ 0.12.2 // APIリクエストを行いローカルタイムラインを表示するプリセット @@ -175,6 +255,11 @@ function selectPreset(ev: MouseEvent) { script = PRESET_OMIKUJI; }, }, { + text: 'Shuffle', + action: () => { + script = PRESET_SHUFFLE; + }, + }, { text: 'Timeline viewer', action: () => { script = PRESET_TIMELINE; @@ -212,6 +297,19 @@ function show() { } } +async function del() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('deleteAreYouSure', { x: flash.title }), + }); + if (canceled) return; + + await os.apiWithDialog('flash/delete', { + flashId: props.id, + }); + router.push('/play'); +} + const headerActions = $computed(() => []); const headerTabs = $computed(() => []); diff --git a/packages/frontend/src/pages/flash/flash-index.vue b/packages/frontend/src/pages/flash/flash-index.vue index 7a1080d3f0..a3a48d3b97 100644 --- a/packages/frontend/src/pages/flash/flash-index.vue +++ b/packages/frontend/src/pages/flash/flash-index.vue @@ -11,12 +11,14 @@ </div> <div v-else-if="tab === 'my'" class="my"> - <MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton> - <MkPagination v-slot="{items}" :pagination="myFlashsPagination"> - <div class="_gaps_s"> - <MkFlashPreview v-for="flash in items" :key="flash.id" class="" :flash="flash"/> - </div> - </MkPagination> + <div class="_gaps"> + <MkButton class="new" gradate rounded style="margin: 0 auto;" @click="create()"><i class="ti ti-plus"></i></MkButton> + <MkPagination v-slot="{items}" :pagination="myFlashsPagination"> + <div class="_gaps_s"> + <MkFlashPreview v-for="flash in items" :key="flash.id" class="" :flash="flash"/> + </div> + </MkPagination> + </div> </div> <div v-else-if="tab === 'liked'" class=""> diff --git a/packages/frontend/src/pages/messaging/messaging-room.vue b/packages/frontend/src/pages/messaging/messaging-room.vue index f1749d449d..0867f003a3 100644 --- a/packages/frontend/src/pages/messaging/messaging-room.vue +++ b/packages/frontend/src/pages/messaging/messaging-room.vue @@ -1,11 +1,15 @@ <template> +<MkStickyContainer> +<template #header> + <MkPageHeader /> +</template> <div ref="rootEl" - class="root" + :class="$style['root']" @dragover.prevent.stop="onDragover" @drop.prevent.stop="onDrop" > - <div class="body"> + <div :class="$style['body']"> <MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination"> <template #empty> <div class="_fullinfo"> @@ -17,7 +21,7 @@ <MkDateSeparatedList v-if="messages.length > 0" v-slot="{ item: message }" - :class="{ messages: true, 'deny-move-transition': pFetching }" + :class="{ [$style['messages']]: true, 'deny-move-transition': pFetching }" :items="messages" direction="up" reversed @@ -27,23 +31,26 @@ </template> </MkPagination> </div> - <footer> - <div v-if="typers.length > 0" class="typers"> - <I18n :src="i18n.ts.typingUsers" text-tag="span" class="users"> + <footer :class="$style['footer']"> + <div v-if="typers.length > 0" :class="$style['typers']"> + <I18n :src="i18n.ts.typingUsers" text-tag="span"> <template #users> - <b v-for="typer in typers" :key="typer.id" class="user">{{ typer.username }}</b> + <b v-for="typer in typers" :key="typer.id" :class="$style['user']">{{ typer.username }}</b> </template> </I18n> <MkEllipsis/> </div> <Transition :name="animation ? 'fade' : ''"> - <div v-show="showIndicator" class="new-message"> - <button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas ti-fw fa-arrow-circle-down"></i>{{ i18n.ts.newMessageExists }}</button> + <div v-show="showIndicator" :class="$style['new-message']"> + <button class="_buttonPrimary" @click="onIndicatorClick" :class="$style['new-message-button']"> + <i class="fas ti-fw fa-arrow-circle-down" :class="$style['new-message-icon']"></i>{{ i18n.ts.newMessageExists }} + </button> </div> </Transition> - <XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/> + <XForm v-if="!fetching" ref="formEl" :user="user" :group="group" :class="$style['form']"/> </footer> </div> +</MkStickyContainer> </template> <script lang="ts" setup> @@ -303,103 +310,98 @@ definePageMetadata(computed(() => !fetching ? user ? { } : null)); </script> -<style lang="scss" scoped> +<style lang="scss" module> .root { display: content; +} - > .body { - min-height: 80%; - - .more { - display: block; - margin: 16px auto; - padding: 0 12px; - line-height: 24px; - color: #fff; - background: rgba(#000, 0.3); - border-radius: 12px; - - &:hover { - background: rgba(#000, 0.4); - } - - &:active { - background: rgba(#000, 0.5); - } +.body { + min-height: 80%; +} - &.fetching { - cursor: wait; - } +.more { + display: block; + margin: 16px auto; + padding: 0 12px; + line-height: 24px; + color: #fff; + background: rgba(#000, 0.3); + border-radius: 12px; + &:hover { + background: rgba(#000, 0.4); + } + &:active { + background: rgba(#000, 0.5); + } + > i { + margin-right: 4px; + } +} - > i { - margin-right: 4px; - } - } +.fetching { + cursor: wait; +} - .messages { - padding: 8px 0; +.messages { + padding: 16px 0 0; - > ::v-deep(*) { - margin-bottom: 16px; - } - } + > * { + margin-bottom: 16px; } +} - > footer { - width: 100%; - position: sticky; - z-index: 2; - padding-top: 8px; - bottom: 0; - bottom: env(safe-area-inset-bottom, 0px); +.footer { + width: 100%; + position: sticky; + z-index: 2; + padding-top: 8px; + bottom: var(--minBottomSpacing); +} - > .new-message { - width: 100%; - padding-bottom: 8px; - text-align: center; +.new-message { + width: 100%; + padding-bottom: 8px; + text-align: center; +} - > button { - display: inline-block; - margin: 0; - padding: 0 12px; - line-height: 32px; - font-size: 12px; - border-radius: 16px; +.new-message-button { + display: inline-block; + margin: 0; + padding: 0 12px; + line-height: 32px; + font-size: 12px; + border-radius: 16px; +} - > i { - display: inline-block; - margin-right: 8px; - } - } - } +.new-message-icon { + display: inline-block; + margin-right: 8px; +} - > .typers { - position: absolute; - bottom: 100%; - padding: 0 8px 0 8px; - font-size: 0.9em; - color: var(--fgTransparentWeak); +.typers { + position: absolute; + bottom: 100%; + padding: 0 8px 0 8px; + font-size: 0.9em; + color: var(--fgTransparentWeak); +} - > .users { - > .user + .user:before { - content: ", "; - font-weight: normal; - } - > .user:last-of-type:after { - content: " "; - } - } - } +.user + .user:before { + content: ", "; + font-weight: normal; +} - > .form { - max-height: 12em; - overflow-y: scroll; - border-top: solid 0.5px var(--divider); - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - } - } +.user:last-of-type:after { + content: " "; +} + +.form { + max-height: 12em; + overflow-y: scroll; + border-top: solid 0.5px var(--divider); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } .fade-enter-active, .fade-leave-active { diff --git a/packages/frontend/src/pages/scratchpad.vue b/packages/frontend/src/pages/scratchpad.vue index bd68df724e..0d52850b5d 100644 --- a/packages/frontend/src/pages/scratchpad.vue +++ b/packages/frontend/src/pages/scratchpad.vue @@ -47,6 +47,7 @@ import { definePageMetadata } from '@/scripts/page-metadata'; import { AsUiComponent, AsUiRoot, patch, registerAsUiLib, render } from '@/scripts/aiscript/ui'; import MkAsUi from '@/components/MkAsUi.vue'; import { miLocalStorage } from '@/local-storage'; +import { claimAchievement } from '@/scripts/achievements'; const parser = new Parser(); let aiscript: Interpreter; @@ -90,6 +91,9 @@ async function run() { }); }, out: (value) => { + if (value.type === 'str' && value.value.toLowerCase().replace(',', '').includes('hello world')) { + claimAchievement('outputHelloWorldOnScratchpad'); + } logs.value.push({ id: Math.random(), text: value.type === 'str' ? value.value : utils.valToString(value), diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index ae74224db6..da7d3d3703 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -85,6 +85,7 @@ import { i18n } from '@/i18n'; import { $i } from '@/account'; import { langmap } from '@/scripts/langmap'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { claimAchievement } from '@/scripts/achievements'; const profile = reactive({ name: $i.name, @@ -133,6 +134,13 @@ function save() { isCat: !!profile.isCat, showTimelineReplies: !!profile.showTimelineReplies, }); + claimAchievement('profileFilled'); + if (profile.name === 'syuilo' || profile.name === 'しゅいろ') { + claimAchievement('setNameToSyuilo'); + } + if (profile.isCat) { + claimAchievement('markedAsCat'); + } } function changeAvatar(ev) { @@ -155,6 +163,7 @@ function changeAvatar(ev) { }); $i.avatarId = i.avatarId; $i.avatarUrl = i.avatarUrl; + claimAchievement('profileFilled'); }); } diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue index 13942da7c7..7ba8a3d16b 100644 --- a/packages/frontend/src/pages/user-info.vue +++ b/packages/frontend/src/pages/user-info.vue @@ -113,7 +113,8 @@ <div v-for="role in info.roles" :key="role.id" :class="$style.roleItem"> <MkRolePreview :class="$style.role" :role="role"/> - <button class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button> + <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button> + <button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button> </div> </div> </MkFolder> diff --git a/packages/frontend/src/pages/user/achievements.vue b/packages/frontend/src/pages/user/achievements.vue new file mode 100644 index 0000000000..eaea26db4a --- /dev/null +++ b/packages/frontend/src/pages/user/achievements.vue @@ -0,0 +1,52 @@ +<template> +<MkSpacer :content-max="1200"> + <MkAchievements :user="user" :with-locked="false"/> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'; +import * as misskey from 'misskey-js'; +import MkAchievements from '@/components/MkAchievements.vue'; +import { i18n } from '@/i18n'; +import { claimAchievement } from '@/scripts/achievements'; +import { $i } from '@/account'; + +const props = defineProps<{ + user: misskey.entities.User; +}>(); + +let timer: number | null; + +function viewAchievements3min() { + if ($i && (props.user.id === $i.id)) { + claimAchievement('viewAchievements3min'); + } +} + +onMounted(() => { + if (timer == null) timer = window.setTimeout(viewAchievements3min, 1000 * 60 * 3); +}); + +onUnmounted(() => { + if (timer != null) { + window.clearTimeout(timer); + timer = null; + } +}); + +onActivated(() => { + if (timer == null) timer = window.setTimeout(viewAchievements3min, 1000 * 60 * 3); +}); + +onDeactivated(() => { + if (timer != null) { + window.clearTimeout(timer); + timer = null; + } +}); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue index 7abae1ea30..d63aa3a3a5 100644 --- a/packages/frontend/src/pages/user/index.vue +++ b/packages/frontend/src/pages/user/index.vue @@ -6,6 +6,7 @@ <div v-if="user"> <XHome v-if="tab === 'home'" :user="user"/> <XActivity v-else-if="tab === 'activity'" :user="user"/> + <XAchievements v-else-if="tab === 'achievements'" :user="user"/> <XReactions v-else-if="tab === 'reactions'" :user="user"/> <XClips v-else-if="tab === 'clips'" :user="user"/> <XPages v-else-if="tab === 'pages'" :user="user"/> @@ -34,6 +35,7 @@ import { $i } from '@/account'; const XHome = defineAsyncComponent(() => import('./home.vue')); const XActivity = defineAsyncComponent(() => import('./activity.vue')); +const XAchievements = defineAsyncComponent(() => import('./achievements.vue')); const XReactions = defineAsyncComponent(() => import('./reactions.vue')); const XClips = defineAsyncComponent(() => import('./clips.vue')); const XPages = defineAsyncComponent(() => import('./pages.vue')); @@ -76,7 +78,11 @@ const headerTabs = $computed(() => user ? [{ key: 'activity', title: i18n.ts.activity, icon: 'ti ti-chart-line', -}, ...($i && ($i.id === user.id)) || user.publicReactions ? [{ +}, ...(user.host == null ? [{ + key: 'achievements', + title: i18n.ts.achievements, + icon: 'ti ti-military-award', +}] : []), ...($i && ($i.id === user.id)) || user.publicReactions ? [{ key: 'reactions', title: i18n.ts.reaction, icon: 'ti ti-mood-happy', diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index 26c73c610f..22106e1595 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -428,6 +428,10 @@ export const routes = [{ component: page(() => import('./pages/favorites.vue')), loginRequired: true, }, { + path: '/my/achievements', + component: page(() => import('./pages/achievements.vue')), + loginRequired: true, +}, { name: 'messaging', path: '/my/messaging', component: page(() => import('./pages/messaging/index.vue')), diff --git a/packages/frontend/src/scripts/achievements.ts b/packages/frontend/src/scripts/achievements.ts new file mode 100644 index 0000000000..8f484f8925 --- /dev/null +++ b/packages/frontend/src/scripts/achievements.ts @@ -0,0 +1,449 @@ +import * as os from '@/os'; +import { $i } from '@/account'; + +export const ACHIEVEMENT_TYPES = [ + 'notes1', + 'notes10', + 'notes100', + 'notes500', + 'notes1000', + 'notes5000', + 'notes10000', + 'notes20000', + 'notes30000', + 'notes40000', + 'notes50000', + 'notes60000', + 'notes70000', + 'notes80000', + 'notes90000', + 'notes100000', + 'login3', + 'login7', + 'login15', + 'login30', + 'login60', + 'login100', + 'login200', + 'login300', + 'login400', + 'login500', + 'login600', + 'login700', + 'login800', + 'login900', + 'login1000', + 'passedSinceAccountCreated1', + 'passedSinceAccountCreated2', + 'passedSinceAccountCreated3', + 'loggedInOnBirthday', + 'loggedInOnNewYearsDay', + 'noteClipped1', + 'noteFavorited1', + 'profileFilled', + 'markedAsCat', + 'following1', + 'following10', + 'following50', + 'following100', + 'following300', + 'followers1', + 'followers10', + 'followers50', + 'followers100', + 'followers300', + 'followers500', + 'followers1000', + 'collectAchievements30', + 'viewAchievements3min', + 'iLoveMisskey', + 'client30min', + 'noteDeletedWithin1min', + 'postedAtLateNight', + 'postedAt0min0sec', + 'selfQuote', + 'htl20npm', + 'outputHelloWorldOnScratchpad', + 'open3windows', + 'driveFolderCircularReference', + 'reactWithoutRead', + 'clickedClickHere', + 'justPlainLucky', + 'setNameToSyuilo', + 'cookieClicked', + 'brainDiver', +] as const; + +export const ACHIEVEMENT_BADGES = { + 'notes1': { + img: '/fluent-emoji/1f4dd.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'notes10': { + img: '/fluent-emoji/1f4d1.png', + bg: null, + frame: 'bronze', + }, + 'notes100': { + img: '/fluent-emoji/1f4d2.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'notes500': { + img: '/fluent-emoji/1f4da.png', + bg: null, + frame: 'bronze', + }, + 'notes1000': { + img: '/fluent-emoji/1f5c3.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'notes5000': { + img: '/fluent-emoji/1f304.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'notes10000': { + img: '/fluent-emoji/1f3d9.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'silver', + }, + 'notes20000': { + img: '/fluent-emoji/1f307.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'silver', + }, + 'notes30000': { + img: '/fluent-emoji/1f306.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'silver', + }, + 'notes40000': { + img: '/fluent-emoji/1f303.png', + bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))', + frame: 'silver', + }, + 'notes50000': { + img: '/fluent-emoji/1fa90.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'gold', + }, + 'notes60000': { + img: '/fluent-emoji/2604.png', + bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))', + frame: 'gold', + }, + 'notes70000': { + img: '/fluent-emoji/1f30c.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'gold', + }, + 'notes80000': { + img: '/fluent-emoji/1f30c.png', + bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))', + frame: 'gold', + }, + 'notes90000': { + img: '/fluent-emoji/1f30c.png', + bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))', + frame: 'gold', + }, + 'notes100000': { + img: '/fluent-emoji/267e.png', + bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))', + frame: 'platinum', + }, + 'login3': { + img: '/fluent-emoji/1f331.png', + bg: null, + frame: 'bronze', + }, + 'login7': { + img: '/fluent-emoji/1f331.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'login15': { + img: '/fluent-emoji/1f331.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'bronze', + }, + 'login30': { + img: '/fluent-emoji/1fab4.png', + bg: null, + frame: 'bronze', + }, + 'login60': { + img: '/fluent-emoji/1fab4.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'login100': { + img: '/fluent-emoji/1fab4.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'silver', + }, + 'login200': { + img: '/fluent-emoji/1f333.png', + bg: null, + frame: 'silver', + }, + 'login300': { + img: '/fluent-emoji/1f333.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'silver', + }, + 'login400': { + img: '/fluent-emoji/1f333.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'silver', + }, + 'login500': { + img: '/fluent-emoji/1f304.png', + bg: null, + frame: 'silver', + }, + 'login600': { + img: '/fluent-emoji/1f304.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'gold', + }, + 'login700': { + img: '/fluent-emoji/1f304.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'gold', + }, + 'login800': { + img: '/fluent-emoji/1f307.png', + bg: null, + frame: 'gold', + }, + 'login900': { + img: '/fluent-emoji/1f307.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'gold', + }, + 'login1000': { + img: '/fluent-emoji/1f307.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'platinum', + }, + 'noteClipped1': { + img: '/fluent-emoji/1f587.png', + bg: null, + frame: 'bronze', + }, + 'noteFavorited1': { + img: '/fluent-emoji/1f31f.png', + bg: null, + frame: 'bronze', + }, + 'profileFilled': { + img: '/fluent-emoji/1f44c.png', + bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', + frame: 'bronze', + }, + 'markedAsCat': { + img: '/fluent-emoji/1f408.png', + bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', + frame: 'bronze', + }, + 'following1': { + img: '/fluent-emoji/2618.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'following10': { + img: '/fluent-emoji/1f6b8.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'following50': { + img: '/fluent-emoji/1f91d.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'following100': { + img: '/fluent-emoji/1f4af.png', + bg: 'linear-gradient(0deg, rgb(255 53 184), rgb(255 206 69))', + frame: 'silver', + }, + 'following300': { + img: '/fluent-emoji/1f970.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'silver', + }, + 'followers1': { + img: '/fluent-emoji/2618.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'followers10': { + img: '/fluent-emoji/1f44b.png', + bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))', + frame: 'bronze', + }, + 'followers50': { + img: '/fluent-emoji/1f411.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'bronze', + }, + 'followers100': { + img: '/fluent-emoji/1f60e.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'silver', + }, + 'followers300': { + img: '/fluent-emoji/1f3c6.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'silver', + }, + 'followers500': { + img: '/fluent-emoji/1f4e1.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'gold', + }, + 'followers1000': { + img: '/fluent-emoji/1f451.png', + bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))', + frame: 'platinum', + }, + 'collectAchievements30': { + img: '/fluent-emoji/1f3c5.png', + bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))', + frame: 'silver', + }, + 'viewAchievements3min': { + img: '/fluent-emoji/1f3c5.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'bronze', + }, + 'iLoveMisskey': { + img: '/fluent-emoji/2764.png', + bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))', + frame: 'silver', + }, + 'client30min': { + img: '/fluent-emoji/1f552.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'bronze', + }, + 'noteDeletedWithin1min': { + img: '/fluent-emoji/1f5d1.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'bronze', + }, + 'postedAtLateNight': { + img: '/fluent-emoji/1f319.png', + bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))', + frame: 'bronze', + }, + 'postedAt0min0sec': { + img: '/fluent-emoji/1f55b.png', + bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))', + frame: 'bronze', + }, + 'selfQuote': { + img: '/fluent-emoji/1f4dd.png', + bg: null, + frame: 'bronze', + }, + 'htl20npm': { + img: '/fluent-emoji/1f30a.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'bronze', + }, + 'outputHelloWorldOnScratchpad': { + img: '/fluent-emoji/1f530.png', + bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))', + frame: 'bronze', + }, + 'open3windows': { + img: '/fluent-emoji/1f5a5.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'bronze', + }, + 'driveFolderCircularReference': { + img: '/fluent-emoji/1f4c2.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'bronze', + }, + 'reactWithoutRead': { + img: '/fluent-emoji/2753.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'bronze', + }, + 'clickedClickHere': { + img: '/fluent-emoji/2757.png', + bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))', + frame: 'bronze', + }, + 'justPlainLucky': { + img: '/fluent-emoji/1f340.png', + bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', + frame: 'silver', + }, + 'setNameToSyuilo': { + img: '/fluent-emoji/1f36e.png', + bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', + frame: 'bronze', + }, + 'passedSinceAccountCreated1': { + img: '/fluent-emoji/0031-20e3.png', + bg: null, + frame: 'bronze', + }, + 'passedSinceAccountCreated2': { + img: '/fluent-emoji/0032-20e3.png', + bg: null, + frame: 'silver', + }, + 'passedSinceAccountCreated3': { + img: '/fluent-emoji/0033-20e3.png', + bg: null, + frame: 'gold', + }, + 'loggedInOnBirthday': { + img: '/fluent-emoji/1f382.png', + bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))', + frame: 'silver', + }, + 'loggedInOnNewYearsDay': { + img: '/fluent-emoji/1f38d.png', + bg: 'linear-gradient(0deg, rgb(255 144 144), rgb(255 232 168))', + frame: 'silver', + }, + 'cookieClicked': { + img: '/fluent-emoji/1f36a.png', + bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))', + frame: 'bronze', + }, + 'brainDiver': { + img: '/fluent-emoji/1f9e0.png', + bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))', + frame: 'bronze', + }, +} as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], { + img: string; + bg: string | null; + frame: 'bronze' | 'silver' | 'gold' | 'platinum'; +}>; + +export const claimedAchievements = ($i && $i.achievements) ? $i.achievements.map(x => x.name) : []; + +export function claimAchievement(type: typeof ACHIEVEMENT_TYPES[number]) { + if (claimedAchievements.includes(type)) return; + os.api('i/claim-achievement', { name: type }); + claimedAchievements.push(type); +} + +if (_DEV_) { + (window as any).unlockAllAchievements = async () => { + for (const t of ACHIEVEMENT_TYPES) { + await new Promise(resolve => setTimeout(resolve, 100)); + claimAchievement(t); + } + }; +} diff --git a/packages/frontend/src/scripts/aiscript/ui.ts b/packages/frontend/src/scripts/aiscript/ui.ts index 2555cd391b..b1895a5f33 100644 --- a/packages/frontend/src/scripts/aiscript/ui.ts +++ b/packages/frontend/src/scripts/aiscript/ui.ts @@ -50,6 +50,7 @@ export type AsUiButton = AsUiComponentBase & { onClick?: () => void; primary?: boolean; rounded?: boolean; + disabled?: boolean; }; export type AsUiButtons = AsUiComponentBase & { @@ -302,6 +303,8 @@ function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, if (primary) utils.assertBoolean(primary); const rounded = def.value.get('rounded'); if (rounded) utils.assertBoolean(rounded); + const disabled = button.value.get('disabled'); + if (disabled) utils.assertBoolean(disabled); return { text: text?.value, @@ -310,6 +313,7 @@ function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, }, primary: primary?.value, rounded: rounded?.value, + disabled: disabled?.value, }; } @@ -330,6 +334,8 @@ function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, if (primary) utils.assertBoolean(primary); const rounded = button.value.get('rounded'); if (rounded) utils.assertBoolean(rounded); + const disabled = button.value.get('disabled'); + if (disabled) utils.assertBoolean(disabled); return { text: text.value, @@ -338,6 +344,7 @@ function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, }, primary: primary?.value, rounded: rounded?.value, + disabled: disabled?.value, }; }) : [], }; diff --git a/packages/frontend/src/scripts/api.ts b/packages/frontend/src/scripts/api.ts index f9fd11f069..5f34f5333e 100644 --- a/packages/frontend/src/scripts/api.ts +++ b/packages/frontend/src/scripts/api.ts @@ -45,7 +45,7 @@ export function api<E extends keyof Endpoints, P extends Endpoints[E]['req']>(en } // Implements Misskey.api.ApiClient.request -export function apiGet<E extends keyof Endpoints, P extends Endpoints[E]['req']>(endpoint: E, data: P = {} as any): Promise<Endpoints[E]['res']> { +export function apiGet <E extends keyof Endpoints, P extends Endpoints[E]['req']>(endpoint: E, data: P = {} as any): Promise<Endpoints[E]['res']> { pendingApiRequestsCount.value++; const onFinally = () => { diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index 54de3d95df..b5d2251d28 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -1,6 +1,7 @@ import { defineAsyncComponent, Ref, inject } from 'vue'; import * as misskey from 'misskey-js'; import { pleaseLogin } from './please-login'; +import { claimAchievement } from './achievements'; import { $i } from '@/account'; import { i18n } from '@/i18n'; import { instance } from '@/instance'; @@ -38,6 +39,10 @@ export function getNoteMenu(props: { os.api('notes/delete', { noteId: appearNote.id, }); + + if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60) { + claimAchievement('noteDeletedWithin1min'); + } }); } @@ -53,10 +58,15 @@ export function getNoteMenu(props: { }); os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel }); + + if (Date.now() - new Date(appearNote.createdAt).getTime() < 1000 * 60) { + claimAchievement('noteDeletedWithin1min'); + } }); } function toggleFavorite(favorite: boolean): void { + claimAchievement('noteFavorited1'); os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { noteId: appearNote.id, }); @@ -118,11 +128,13 @@ export function getNoteMenu(props: { const clip = await os.apiWithDialog('clips/create', result); + claimAchievement('noteClipped1'); os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); }, }, null, ...clips.map(clip => ({ text: clip.name, action: () => { + claimAchievement('noteClipped1'); os.promiseDialog( os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }), null, @@ -174,9 +186,17 @@ export function getNoteMenu(props: { url: `${url}/notes/${appearNote.id}`, }); } - function notedetails(): void { + + function openDetail(): void { os.pageWindow(`/notes/${appearNote.id}`); } + + function showReactions(): void { + os.popup(defineAsyncComponent(() => import('@/components/MkReactedUsersDialog.vue')), { + noteId: appearNote.id, + }, {}, 'closed'); + } + async function translate(): Promise<void> { if (props.translation.value != null) return; props.translating.value = true; @@ -205,7 +225,11 @@ export function getNoteMenu(props: { ), { icon: 'ti ti-info-circle', text: i18n.ts.details, - action: notedetails, + action: openDetail, + }, { + icon: 'ti ti-users', + text: i18n.ts.reactions, + action: showReactions, }, { icon: 'ti ti-copy', text: i18n.ts.copyContent, diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index ba82eda609..0bf35ec1b4 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -97,6 +97,7 @@ export default defineConfig(({ command, mode }) => { output: { manualChunks: { vue: ['vue'], + photoswipe: ['photoswipe', 'photoswipe/lightbox', 'photoswipe/style.css'], }, }, }, |