Merge branch 'develop' into emoji-re
This commit is contained in:
@@ -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');
|
||||
|
224
packages/frontend/src/components/MkAchievements.vue
Normal file
224
packages/frontend/src/components/MkAchievements.vue
Normal file
@@ -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>
|
@@ -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;
|
||||
|
@@ -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(() => {
|
||||
|
@@ -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,
|
||||
});
|
||||
|
@@ -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,
|
||||
});
|
||||
|
@@ -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) {
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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();
|
||||
});
|
||||
|
@@ -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();
|
||||
});
|
||||
|
@@ -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 {
|
||||
|
@@ -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>
|
||||
|
@@ -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,
|
||||
});
|
||||
|
@@ -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;
|
||||
|
92
packages/frontend/src/components/MkReactedUsersDialog.vue
Normal file
92
packages/frontend/src/components/MkReactedUsersDialog.vue
Normal file
@@ -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>
|
@@ -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');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@@ -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>
|
||||
|
||||
|
@@ -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>
|
||||
@@ -11,6 +12,7 @@ import { getStaticImageUrl } from '@/scripts/media-proxy';
|
||||
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base';
|
||||
import { defaultStore } from '@/store';
|
||||
import { getEmojiName } from '@/scripts/emojilist';
|
||||
import { customEmojis } from '@/custom-emojis';
|
||||
|
||||
const props = defineProps<{
|
||||
emoji: string;
|
||||
@@ -23,12 +25,15 @@ const props = defineProps<{
|
||||
const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
|
||||
|
||||
const isCustom = computed(() => props.emoji.startsWith(':'));
|
||||
const customEmojiName = props.emoji.substr(1, props.emoji.length - 2);
|
||||
const customEmojiName = props.emoji.substr(1, props.emoji.length - 2).replace('@.', '');
|
||||
const char = computed(() => isCustom.value ? undefined : props.emoji);
|
||||
const useOsNativeEmojis = computed(() => defaultStore.state.emojiStyle === 'native' && !props.isReaction);
|
||||
const url = computed(() => {
|
||||
if (char.value) {
|
||||
return char2path(char.value);
|
||||
} else if (props.host == null && !customEmojiName.includes('@')) {
|
||||
const found = customEmojis.find(x => x.name === customEmojiName);
|
||||
return found ? found.url : null;
|
||||
} else {
|
||||
const rawUrl = props.host ? `/emoji/${customEmojiName}@${props.host}.webp` : `/emoji/${customEmojiName}.webp`;
|
||||
return defaultStore.state.disableShowingAnimatedImages
|
||||
@@ -37,6 +42,7 @@ const url = computed(() => {
|
||||
}
|
||||
});
|
||||
const alt = computed(() => isCustom.value ? `:${customEmojiName}:` : char.value);
|
||||
let errored = $ref(isCustom.value && url.value == null);
|
||||
|
||||
// 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 {
|
||||
|
@@ -200,6 +200,12 @@ export default defineComponent({
|
||||
style = `transform: translateX(${x}em) translateY(${y}em);`;
|
||||
break;
|
||||
}
|
||||
case 'scale': {
|
||||
const x = Math.min(parseInt(token.props.args.x ?? '1'), 5);
|
||||
const y = Math.min(parseInt(token.props.args.y ?? '1'), 5);
|
||||
style = `transform: scale(${x}, ${y});`;
|
||||
break;
|
||||
}
|
||||
case 'fg': {
|
||||
let color = token.props.args.color;
|
||||
if (!/^[0-9a-f]{3,6}$/i.test(color)) color = 'f00';
|
||||
|
@@ -10,8 +10,12 @@ export const apiUrl = url + '/api';
|
||||
export const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss://') + '/streaming';
|
||||
export const lang = miLocalStorage.getItem('lang');
|
||||
export const langs = _LANGS_;
|
||||
export const locale = JSON.parse(miLocalStorage.getItem('locale'));
|
||||
export let locale = JSON.parse(miLocalStorage.getItem('locale'));
|
||||
export const version = _VERSION_;
|
||||
export const instanceName = siteName === 'Misskey' ? host : siteName;
|
||||
export const ui = miLocalStorage.getItem('ui');
|
||||
export const debug = miLocalStorage.getItem('debug') === 'true';
|
||||
|
||||
export function updateLocale(newLocale) {
|
||||
locale = newLocale;
|
||||
}
|
||||
|
@@ -3,3 +3,7 @@ import { locale } from '@/config';
|
||||
import { I18n } from '@/scripts/i18n';
|
||||
|
||||
export const i18n = markRaw(new I18n(locale));
|
||||
|
||||
export function updateI18n(newLocale) {
|
||||
i18n.ts = newLocale;
|
||||
}
|
||||
|
@@ -25,10 +25,10 @@ import JSON5 from 'json5';
|
||||
import widgets from '@/widgets';
|
||||
import directives from '@/directives';
|
||||
import components from '@/components';
|
||||
import { version, ui, lang, host } from '@/config';
|
||||
import { version, ui, lang, host, updateLocale } from '@/config';
|
||||
import { applyTheme } from '@/scripts/theme';
|
||||
import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
|
||||
import { i18n } from '@/i18n';
|
||||
import { i18n, updateI18n } from '@/i18n';
|
||||
import { confirm, alert, post, popup, toast } from '@/os';
|
||||
import { stream } from '@/stream';
|
||||
import * as sound from '@/scripts/sound';
|
||||
@@ -44,6 +44,8 @@ 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';
|
||||
import { fetchCustomEmojis } from './custom-emojis';
|
||||
|
||||
(async () => {
|
||||
console.info(`Misskey v${version}`);
|
||||
@@ -79,6 +81,22 @@ import { miLocalStorage } from './local-storage';
|
||||
});
|
||||
}
|
||||
|
||||
//#region Detect language & fetch translations
|
||||
const localeVersion = miLocalStorage.getItem('localeVersion');
|
||||
const localeOutdated = (localeVersion == null || localeVersion !== version);
|
||||
if (localeOutdated) {
|
||||
const res = await window.fetch(`/assets/locales/${lang}.${version}.json`);
|
||||
if (res.status === 200) {
|
||||
const newLocale = await res.text();
|
||||
const parsedNewLocale = JSON.parse(newLocale);
|
||||
miLocalStorage.setItem('locale', newLocale);
|
||||
miLocalStorage.setItem('localeVersion', version);
|
||||
updateLocale(parsedNewLocale);
|
||||
updateI18n(parsedNewLocale);
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
// タッチデバイスでCSSの:hoverを機能させる
|
||||
document.addEventListener('touchend', () => {}, { passive: true });
|
||||
|
||||
@@ -164,6 +182,10 @@ import { miLocalStorage } from './local-storage';
|
||||
initializeSw();
|
||||
});
|
||||
|
||||
try {
|
||||
await fetchCustomEmojis();
|
||||
} catch (err) {}
|
||||
|
||||
const app = createApp(
|
||||
window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
|
||||
!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
|
||||
@@ -345,6 +367,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);
|
||||
|
@@ -19,6 +19,7 @@ type Keys =
|
||||
'fontSize' |
|
||||
'ui' |
|
||||
'locale' |
|
||||
'localeVersion' |
|
||||
'theme' |
|
||||
'customCss' |
|
||||
'message_drafts' |
|
||||
|
@@ -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',
|
||||
|
@@ -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,
|
||||
}, {
|
||||
|
@@ -4,11 +4,14 @@
|
||||
<div style="overflow: clip;">
|
||||
<MkSpacer :content-max="600" :margin-min="20">
|
||||
<div class="_gaps_m znqjceqz">
|
||||
<div ref="containerEl" v-panel class="about" :class="{ playing: easterEggEngine != null }">
|
||||
<img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/>
|
||||
<div class="misskey">Misskey</div>
|
||||
<div class="version">v{{ version }}</div>
|
||||
<span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :is-reaction="false" :normal="true" :no-style="true"/></span>
|
||||
<div v-panel class="about">
|
||||
<div ref="containerEl" class="container" :class="{ playing: easterEggEngine != null }">
|
||||
<img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/>
|
||||
<div class="misskey">Misskey</div>
|
||||
<div class="version">v{{ version }}</div>
|
||||
<span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :is-reaction="false" :normal="true" :no-style="true"/></span>
|
||||
</div>
|
||||
<button v-if="thereIsTreasure" class="_button treasure" @click="getTreasure"><img src="/fluent-emoji/1f3c6.png" class="treasureImg"></button>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
{{ i18n.ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ i18n.ts.learnMore }}</a>
|
||||
@@ -70,6 +73,8 @@ import { i18n } from '@/i18n';
|
||||
import { defaultStore } from '@/store';
|
||||
import * as os from '@/os';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import { claimAchievement, claimedAchievements } from '@/scripts/achievements';
|
||||
import { $i } from '@/account';
|
||||
|
||||
const patrons = [
|
||||
'まっちゃとーにゅ',
|
||||
@@ -152,6 +157,8 @@ const patrons = [
|
||||
'pixeldesu',
|
||||
];
|
||||
|
||||
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));
|
||||
|
||||
let easterEggReady = false;
|
||||
let easterEggEmojis = $ref([]);
|
||||
let easterEggEngine = $ref(null);
|
||||
@@ -187,6 +194,11 @@ function iLoveMisskey() {
|
||||
});
|
||||
}
|
||||
|
||||
function getTreasure() {
|
||||
thereIsTreasure = false;
|
||||
claimAchievement('foundTreasure');
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (easterEggEngine) {
|
||||
easterEggEngine.stop();
|
||||
@@ -207,52 +219,77 @@ definePageMetadata({
|
||||
.znqjceqz {
|
||||
> .about {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius);
|
||||
|
||||
&.playing {
|
||||
&, * {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
* {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
> .emoji {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
> .icon {
|
||||
display: block;
|
||||
width: 80px;
|
||||
margin: 0 auto;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
> .misskey {
|
||||
margin: 0.75em auto 0 auto;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
> .version {
|
||||
margin: 0 auto;
|
||||
width: max-content;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
> .emoji {
|
||||
> .treasure {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
visibility: hidden;
|
||||
right: 0;
|
||||
margin: 0 auto;
|
||||
width: min-content;
|
||||
|
||||
> .treasureImg {
|
||||
width: 25px;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
}
|
||||
|
||||
> .container {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
|
||||
&.playing {
|
||||
&, * {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
* {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
> .emoji {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
> .icon {
|
||||
display: block;
|
||||
width: 80px;
|
||||
margin: 0 auto;
|
||||
border-radius: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
> .misskey {
|
||||
margin: 0.75em auto 0 auto;
|
||||
width: max-content;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
> .version {
|
||||
margin: 0 auto;
|
||||
width: max-content;
|
||||
opacity: 0.5;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
> .emoji {
|
||||
pointer-events: none;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
visibility: hidden;
|
||||
|
||||
> .emoji {
|
||||
pointer-events: none;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -86,7 +86,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import XEmojis from './about.emojis.vue';
|
||||
import XFederation from './about.federation.vue';
|
||||
import { version, instanceName, host } from '@/config';
|
||||
@@ -100,6 +100,7 @@ import * as os from '@/os';
|
||||
import number from '@/filters/number';
|
||||
import { i18n } from '@/i18n';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import { claimAchievement } from '@/scripts/achievements';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
initialTab?: string;
|
||||
@@ -110,6 +111,12 @@ const props = withDefaults(defineProps<{
|
||||
let stats = $ref(null);
|
||||
let tab = $ref(props.initialTab);
|
||||
|
||||
watch($$(tab), () => {
|
||||
if (tab === 'charts') {
|
||||
claimAchievement('viewInstanceChart');
|
||||
}
|
||||
});
|
||||
|
||||
const initStats = () => os.api('stats', {
|
||||
}).then((res) => {
|
||||
stats = res;
|
||||
|
54
packages/frontend/src/pages/achievements.vue
Normal file
54
packages/frontend/src/pages/achievements.vue
Normal file
@@ -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>
|
@@ -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>
|
||||
|
@@ -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リクエストを行いローカルタイムラインを表示するプリセット
|
||||
|
||||
@@ -174,6 +254,11 @@ function selectPreset(ev: MouseEvent) {
|
||||
action: () => {
|
||||
script = PRESET_OMIKUJI;
|
||||
},
|
||||
}, {
|
||||
text: 'Shuffle',
|
||||
action: () => {
|
||||
script = PRESET_SHUFFLE;
|
||||
},
|
||||
}, {
|
||||
text: 'Timeline viewer',
|
||||
action: () => {
|
||||
@@ -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(() => []);
|
||||
|
@@ -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="">
|
||||
|
@@ -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%;
|
||||
.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);
|
||||
}
|
||||
|
||||
&.fetching {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
> i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.messages {
|
||||
padding: 8px 0;
|
||||
|
||||
> ::v-deep(*) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
> footer {
|
||||
width: 100%;
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
padding-top: 8px;
|
||||
bottom: 0;
|
||||
bottom: env(safe-area-inset-bottom, 0px);
|
||||
|
||||
> .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;
|
||||
|
||||
> i {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .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: " ";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .form {
|
||||
max-height: 12em;
|
||||
overflow-y: scroll;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
&:active {
|
||||
background: rgba(#000, 0.5);
|
||||
}
|
||||
> i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.fetching {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.messages {
|
||||
padding: 16px 0 0;
|
||||
|
||||
> * {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.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-button {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
line-height: 32px;
|
||||
font-size: 12px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
|
||||
.user + .user:before {
|
||||
content: ", ";
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
@@ -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),
|
||||
|
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -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>
|
||||
|
52
packages/frontend/src/pages/user/achievements.vue
Normal file
52
packages/frontend/src/pages/user/achievements.vue
Normal file
@@ -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>
|
@@ -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',
|
||||
|
@@ -427,6 +427,10 @@ export const routes = [{
|
||||
path: '/my/favorites',
|
||||
component: page(() => import('./pages/favorites.vue')),
|
||||
loginRequired: true,
|
||||
}, {
|
||||
path: '/my/achievements',
|
||||
component: page(() => import('./pages/achievements.vue')),
|
||||
loginRequired: true,
|
||||
}, {
|
||||
name: 'messaging',
|
||||
path: '/my/messaging',
|
||||
|
474
packages/frontend/src/scripts/achievements.ts
Normal file
474
packages/frontend/src/scripts/achievements.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
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',
|
||||
'myNoteFavorited1',
|
||||
'profileFilled',
|
||||
'markedAsCat',
|
||||
'following1',
|
||||
'following10',
|
||||
'following50',
|
||||
'following100',
|
||||
'following300',
|
||||
'followers1',
|
||||
'followers10',
|
||||
'followers50',
|
||||
'followers100',
|
||||
'followers300',
|
||||
'followers500',
|
||||
'followers1000',
|
||||
'collectAchievements30',
|
||||
'viewAchievements3min',
|
||||
'iLoveMisskey',
|
||||
'foundTreasure',
|
||||
'client30min',
|
||||
'noteDeletedWithin1min',
|
||||
'postedAtLateNight',
|
||||
'postedAt0min0sec',
|
||||
'selfQuote',
|
||||
'htl20npm',
|
||||
'viewInstanceChart',
|
||||
'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',
|
||||
},
|
||||
'myNoteFavorited1': {
|
||||
img: '/fluent-emoji/1f320.png',
|
||||
bg: null,
|
||||
frame: 'silver',
|
||||
},
|
||||
'profileFilled': {
|
||||
img: '/fluent-emoji/1f44c.png',
|
||||
bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'markedAsCat': {
|
||||
img: '/fluent-emoji/1f408.png',
|
||||
bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'following1': {
|
||||
img: '/fluent-emoji/2618.png',
|
||||
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'following10': {
|
||||
img: '/fluent-emoji/1f6b8.png',
|
||||
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'following50': {
|
||||
img: '/fluent-emoji/1f91d.png',
|
||||
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'following100': {
|
||||
img: '/fluent-emoji/1f4af.png',
|
||||
bg: 'linear-gradient(0deg, rgb(255 53 184), rgb(255 206 69))',
|
||||
frame: 'silver',
|
||||
},
|
||||
'following300': {
|
||||
img: '/fluent-emoji/1f970.png',
|
||||
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
|
||||
frame: 'silver',
|
||||
},
|
||||
'followers1': {
|
||||
img: '/fluent-emoji/2618.png',
|
||||
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'followers10': {
|
||||
img: '/fluent-emoji/1f44b.png',
|
||||
bg: 'linear-gradient(0deg, rgb(59 187 116), rgb(199 211 102))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'followers50': {
|
||||
img: '/fluent-emoji/1f411.png',
|
||||
bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'followers100': {
|
||||
img: '/fluent-emoji/1f60e.png',
|
||||
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
|
||||
frame: 'silver',
|
||||
},
|
||||
'followers300': {
|
||||
img: '/fluent-emoji/1f3c6.png',
|
||||
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
|
||||
frame: 'silver',
|
||||
},
|
||||
'followers500': {
|
||||
img: '/fluent-emoji/1f4e1.png',
|
||||
bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
|
||||
frame: 'gold',
|
||||
},
|
||||
'followers1000': {
|
||||
img: '/fluent-emoji/1f451.png',
|
||||
bg: 'linear-gradient(0deg, rgb(255 232 119), rgb(255 140 41))',
|
||||
frame: 'platinum',
|
||||
},
|
||||
'collectAchievements30': {
|
||||
img: '/fluent-emoji/1f3c5.png',
|
||||
bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
|
||||
frame: 'silver',
|
||||
},
|
||||
'viewAchievements3min': {
|
||||
img: '/fluent-emoji/1f3c5.png',
|
||||
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'iLoveMisskey': {
|
||||
img: '/fluent-emoji/2764.png',
|
||||
bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
|
||||
frame: 'silver',
|
||||
},
|
||||
'foundTreasure': {
|
||||
img: '/fluent-emoji/1f3c6.png',
|
||||
bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
|
||||
frame: 'gold',
|
||||
},
|
||||
'client30min': {
|
||||
img: '/fluent-emoji/1f552.png',
|
||||
bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'noteDeletedWithin1min': {
|
||||
img: '/fluent-emoji/1f5d1.png',
|
||||
bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'postedAtLateNight': {
|
||||
img: '/fluent-emoji/1f319.png',
|
||||
bg: 'linear-gradient(0deg, rgb(197 69 192), rgb(2 112 155))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'postedAt0min0sec': {
|
||||
img: '/fluent-emoji/1f55b.png',
|
||||
bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'selfQuote': {
|
||||
img: '/fluent-emoji/1f4dd.png',
|
||||
bg: null,
|
||||
frame: 'bronze',
|
||||
},
|
||||
'htl20npm': {
|
||||
img: '/fluent-emoji/1f30a.png',
|
||||
bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'viewInstanceChart': {
|
||||
img: '/fluent-emoji/1f4ca.png',
|
||||
bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'outputHelloWorldOnScratchpad': {
|
||||
img: '/fluent-emoji/1f530.png',
|
||||
bg: 'linear-gradient(0deg, rgb(58 231 198), rgb(37 194 255))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'open3windows': {
|
||||
img: '/fluent-emoji/1f5a5.png',
|
||||
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'driveFolderCircularReference': {
|
||||
img: '/fluent-emoji/1f4c2.png',
|
||||
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'reactWithoutRead': {
|
||||
img: '/fluent-emoji/2753.png',
|
||||
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'clickedClickHere': {
|
||||
img: '/fluent-emoji/2757.png',
|
||||
bg: 'linear-gradient(0deg, rgb(144 224 255), rgb(255 168 252))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'justPlainLucky': {
|
||||
img: '/fluent-emoji/1f340.png',
|
||||
bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
|
||||
frame: 'silver',
|
||||
},
|
||||
'setNameToSyuilo': {
|
||||
img: '/fluent-emoji/1f36e.png',
|
||||
bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'passedSinceAccountCreated1': {
|
||||
img: '/fluent-emoji/0031-20e3.png',
|
||||
bg: null,
|
||||
frame: 'bronze',
|
||||
},
|
||||
'passedSinceAccountCreated2': {
|
||||
img: '/fluent-emoji/0032-20e3.png',
|
||||
bg: null,
|
||||
frame: 'silver',
|
||||
},
|
||||
'passedSinceAccountCreated3': {
|
||||
img: '/fluent-emoji/0033-20e3.png',
|
||||
bg: null,
|
||||
frame: 'gold',
|
||||
},
|
||||
'loggedInOnBirthday': {
|
||||
img: '/fluent-emoji/1f382.png',
|
||||
bg: 'linear-gradient(0deg, rgb(255 77 77), rgb(247 155 214))',
|
||||
frame: 'silver',
|
||||
},
|
||||
'loggedInOnNewYearsDay': {
|
||||
img: '/fluent-emoji/1f38d.png',
|
||||
bg: 'linear-gradient(0deg, rgb(255 144 144), rgb(255 232 168))',
|
||||
frame: 'silver',
|
||||
},
|
||||
'cookieClicked': {
|
||||
img: '/fluent-emoji/1f36a.png',
|
||||
bg: 'linear-gradient(0deg, rgb(187 183 59), rgb(255 143 77))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
'brainDiver': {
|
||||
img: '/fluent-emoji/1f9e0.png',
|
||||
bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
|
||||
frame: 'bronze',
|
||||
},
|
||||
} as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], {
|
||||
img: string;
|
||||
bg: string | null;
|
||||
frame: 'bronze' | 'silver' | 'gold' | 'platinum';
|
||||
}>;
|
||||
|
||||
export const claimedAchievements: typeof ACHIEVEMENT_TYPES[number][] = ($i && $i.achievements) ? $i.achievements.map(x => x.name) : [];
|
||||
|
||||
const claimingQueue = new Set<string>();
|
||||
|
||||
export async function claimAchievement(type: typeof ACHIEVEMENT_TYPES[number]) {
|
||||
if ($i == null) return;
|
||||
if (claimedAchievements.includes(type)) return;
|
||||
claimingQueue.add(type);
|
||||
claimedAchievements.push(type);
|
||||
await new Promise(resolve => setTimeout(resolve, (claimingQueue.size - 1) * 500));
|
||||
window.setTimeout(() => {
|
||||
claimingQueue.delete(type);
|
||||
}, 500);
|
||||
os.api('i/claim-achievement', { name: type });
|
||||
}
|
||||
|
||||
if (_DEV_) {
|
||||
(window as any).unlockAllAchievements = () => {
|
||||
for (const t of ACHIEVEMENT_TYPES) {
|
||||
claimAchievement(t);
|
||||
}
|
||||
};
|
||||
}
|
@@ -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,
|
||||
};
|
||||
}) : [],
|
||||
};
|
||||
|
@@ -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 = () => {
|
||||
|
@@ -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,
|
||||
|
@@ -1 +1 @@
|
||||
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'position', 'fg', 'bg', 'font', 'blur', 'rainbow', 'sparkle', 'rotate'];
|
||||
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'font', 'blur', 'rainbow', 'sparkle', 'rotate'];
|
||||
|
Reference in New Issue
Block a user