Merge branch 'notification-read-api' into swn

This commit is contained in:
tamaina 2022-03-12 00:47:44 +09:00
commit 2c698e48cc
88 changed files with 2368 additions and 1236 deletions

View file

@ -70,7 +70,8 @@ const colors = {
red: '#FF4560',
purple: '#e300db',
orange: '#fe6919',
lime: '#c7f400',
lime: '#bde800',
cyan: '#00efef',
};
const colorSets = [colors.blue, colors.green, colors.yellow, colors.red, colors.purple];
const getColor = (i) => {
@ -274,7 +275,7 @@ export default defineComponent({
y: {
position: 'left',
stacked: props.stacked,
suggestedMax: 100,
suggestedMax: 50,
grid: {
color: gridColor,
borderColor: 'rgb(0, 0, 0, 0)',
@ -388,19 +389,29 @@ export default defineComponent({
type: 'area',
data: format(raw.stalled),
color: colors.red,
}, {
name: 'Pub Active',
type: 'line',
data: format(raw.pubActive),
color: colors.purple,
}, {
name: 'Sub Active',
type: 'line',
data: format(raw.subActive),
color: colors.orange,
}, {
name: 'Pub & Sub',
type: 'area',
type: 'line',
data: format(raw.pubsub),
color: colors.lime,
color: colors.cyan,
}, {
name: 'Pub',
type: 'area',
type: 'line',
data: format(raw.pub),
color: colors.purple,
}, {
name: 'Sub',
type: 'area',
type: 'line',
data: format(raw.sub),
color: colors.orange,
}],

View file

@ -1,5 +1,5 @@
<template>
<MkModal ref="modal" v-slot="{ type, maxHeight }" :prefer-type="preferedModalType" :transparent-bg="true" :src="src" @click="modal.close()" @closed="emit('closed')">
<MkModal ref="modal" v-slot="{ type, maxHeight }" :prefer-type="preferedModalType" :anchor="{ x: 'right', y: 'center' }" :transparent-bg="true" :src="src" @click="modal.close()" @closed="emit('closed')">
<div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }">
<div class="main">
<template v-for="item in items">

View file

@ -141,6 +141,10 @@ export default defineComponent({
width: 100%;
height: 100%;
flex-direction: row;
overflow: scroll;
position: fixed;
left: 0;
top: 0;
}
@media (max-width: 850px) {
.container {

View file

@ -154,7 +154,18 @@ const props = defineProps<{
const inChannel = inject('inChannel', null);
const note = $ref(JSON.parse(JSON.stringify(props.note)));
let note = $ref(JSON.parse(JSON.stringify(props.note)));
// plugin
if (noteViewInterruptors.length > 0) {
onMounted(async () => {
let result = JSON.parse(JSON.stringify(note));
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(result);
}
note = result;
});
}
const isRenote = (
note.renote != null &&
@ -168,7 +179,7 @@ const menuButton = ref<HTMLElement>();
const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
const renoteTime = ref<HTMLElement>();
const reactButton = ref<HTMLElement>();
let appearNote = $ref(isRenote ? note.renote as misskey.entities.Note : note);
let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note);
const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false);
const isDeleted = ref(false);

View file

@ -138,7 +138,18 @@ const props = defineProps<{
const inChannel = inject('inChannel', null);
const note = $ref(JSON.parse(JSON.stringify(props.note)));
let note = $ref(JSON.parse(JSON.stringify(props.note)));
// plugin
if (noteViewInterruptors.length > 0) {
onMounted(async () => {
let result = JSON.parse(JSON.stringify(note));
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(result);
}
note = result;
});
}
const isRenote = (
note.renote != null &&
@ -152,7 +163,7 @@ const menuButton = ref<HTMLElement>();
const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
const renoteTime = ref<HTMLElement>();
const reactButton = ref<HTMLElement>();
let appearNote = $ref(isRenote ? note.renote as misskey.entities.Note : note);
let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note);
const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false);
const collapsed = ref(appearNote.cw == null && appearNote.text != null && (

View file

@ -53,7 +53,7 @@ onMounted(() => {
}
@media (max-width: 500px) {
bottom: 92px;
bottom: calc(env(safe-area-inset-bottom, 0px) + 92px);
padding: 0 8px;
}

View file

@ -1,7 +1,8 @@
<template>
<div ref="elRef" v-size="{ max: [500, 600] }" class="qglefbjs" :class="notification.type">
<div class="head">
<MkAvatar v-if="notification.user" class="icon" :user="notification.user"/>
<MkAvatar v-if="notification.type === 'pollEnded'" class="icon" :user="notification.note.user"/>
<MkAvatar v-else-if="notification.user" class="icon" :user="notification.user"/>
<img v-else-if="notification.icon" class="icon" :src="notification.icon" alt=""/>
<div class="sub-icon" :class="notification.type">
<i v-if="notification.type === 'follow'" class="fas fa-plus"></i>
@ -13,6 +14,7 @@
<i v-else-if="notification.type === 'mention'" class="fas fa-at"></i>
<i v-else-if="notification.type === 'quote'" class="fas fa-quote-left"></i>
<i v-else-if="notification.type === 'pollVote'" class="fas fa-poll-h"></i>
<i v-else-if="notification.type === 'pollEnded'" class="fas fa-poll-h"></i>
<!-- notification.reaction null になることはまずないがここでoptional chaining使うと一部ブラウザで刺さるので念の為 -->
<XReactionIcon v-else-if="notification.type === 'reaction'"
ref="reactionRef"
@ -24,7 +26,8 @@
</div>
<div class="tail">
<header>
<MkA v-if="notification.user" v-user-preview="notification.user.id" class="name" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<span v-if="notification.type === 'pollEnded'">{{ i18n.ts._notification.pollEnded }}</span>
<MkA v-else-if="notification.user" v-user-preview="notification.user.id" class="name" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<span v-else>{{ notification.header }}</span>
<MkTime v-if="withTime" :time="notification.createdAt" class="time"/>
</header>
@ -52,6 +55,11 @@
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
<i class="fas fa-quote-right"></i>
</MkA>
<MkA v-if="notification.type === 'pollEnded'" class="text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)">
<i class="fas fa-quote-left"></i>
<Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="!full" :custom-emojis="notification.note.emojis"/>
<i class="fas fa-quote-right"></i>
</MkA>
<span v-if="notification.type === 'follow'" class="text" style="opacity: 0.6;">{{ $ts.youGotNewFollower }}<div v-if="full"><MkFollowButton :user="notification.user" :full="true"/></div></span>
<span v-if="notification.type === 'followRequestAccepted'" class="text" style="opacity: 0.6;">{{ $ts.followRequestAccepted }}</span>
<span v-if="notification.type === 'receiveFollowRequest'" class="text" style="opacity: 0.6;">{{ $ts.receiveFollowRequest }}<div v-if="full && !followRequestDone"><button class="_textButton" @click="acceptFollowRequest()">{{ $ts.accept }}</button> | <button class="_textButton" @click="rejectFollowRequest()">{{ $ts.reject }}</button></div></span>
@ -173,6 +181,7 @@ export default defineComponent({
rejectGroupInvitation,
elRef,
reactionRef,
i18n,
};
},
});
@ -278,6 +287,12 @@ export default defineComponent({
background: #88a6b7;
pointer-events: none;
}
&.pollEnded {
padding: 3px;
background: #88a6b7;
pointer-events: none;
}
}
}

View file

@ -35,19 +35,18 @@ const props = defineProps<{
const pagingComponent = ref<InstanceType<typeof MkPagination>>();
const allIncludeTypes = computed(() => props.includeTypes ?? notificationTypes.filter(x => !$i.mutingNotificationTypes.includes(x)));
const pagination: Paging = {
endpoint: 'i/notifications' as const,
limit: 10,
params: computed(() => ({
includeTypes: allIncludeTypes.value || undefined,
includeTypes: props.includeTypes ?? undefined,
excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes,
unreadOnly: props.unreadOnly,
})),
};
const onNotification = (notification) => {
const isMuted = !allIncludeTypes.value.includes(notification.type);
const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type);
if (isMuted || document.visibilityState === 'visible') {
stream.send('readNotification', {
id: notification.id

View file

@ -30,7 +30,7 @@ type ModalTypes = 'popup' | 'dialog' | 'dialog:top' | 'drawer';
const props = withDefaults(defineProps<{
manualShowing?: boolean | null;
srcCenter?: boolean;
anchor?: { x: string; y: string; };
src?: HTMLElement;
preferType?: ModalTypes | 'auto';
zPriority?: 'low' | 'middle' | 'high';
@ -39,6 +39,7 @@ const props = withDefaults(defineProps<{
}>(), {
manualShowing: null,
src: null,
anchor: { x: 'center', y: 'bottom' },
preferType: 'auto',
zPriority: 'low',
noOverlap: true,
@ -113,16 +114,23 @@ const align = () => {
let left;
let top;
if (props.srcCenter) {
const x = rect.left + (fixed.value ? 0 : window.pageXOffset) + (props.src.offsetWidth / 2);
const y = rect.top + (fixed.value ? 0 : window.pageYOffset) + (props.src.offsetHeight / 2);
left = (x - (width / 2));
const x = rect.left + (fixed.value ? 0 : window.pageXOffset);
const y = rect.top + (fixed.value ? 0 : window.pageYOffset);
if (props.anchor.x === 'center') {
left = x + (props.src.offsetWidth / 2) - (width / 2);
} else if (props.anchor.x === 'left') {
// TODO
} else if (props.anchor.x === 'right') {
left = x + props.src.offsetWidth;
}
if (props.anchor.y === 'center') {
top = (y - (height / 2));
} else {
const x = rect.left + (fixed.value ? 0 : window.pageXOffset) + (props.src.offsetWidth / 2);
const y = rect.top + (fixed.value ? 0 : window.pageYOffset) + props.src.offsetHeight;
left = (x - (width / 2));
top = y;
} else if (props.anchor.y === 'top') {
// TODO
} else if (props.anchor.y === 'bottom') {
top = y + props.src.offsetHeight;
}
if (fixed.value) {
@ -136,7 +144,7 @@ const align = () => {
//
if (top + height > (window.innerHeight - MARGIN)) {
if (props.noOverlap) {
if (props.noOverlap && props.anchor.x === 'center') {
if (underSpace >= (upperSpace / 3)) {
maxHeight.value = underSpace;
} else {
@ -160,7 +168,7 @@ const align = () => {
//
if (top + height - window.pageYOffset > (window.innerHeight - MARGIN)) {
if (props.noOverlap) {
if (props.noOverlap && props.anchor.x === 'center') {
if (underSpace >= (upperSpace / 3)) {
maxHeight.value = underSpace;
} else {
@ -183,14 +191,23 @@ const align = () => {
left = 0;
}
let transformOriginX = 'center';
let transformOriginY = 'center';
if (top > rect.top + (fixed.value ? 0 : window.pageYOffset)) {
transformOrigin.value = 'center top';
transformOriginY = 'top';
} else if ((top + height) <= rect.top + (fixed.value ? 0 : window.pageYOffset)) {
transformOrigin.value = 'center bottom';
} else {
transformOrigin.value = 'center';
transformOriginY = 'bottom';
}
if (left > rect.left + (fixed.value ? 0 : window.pageXOffset)) {
transformOriginY = 'left';
} else if ((left + width) <= rect.left + (fixed.value ? 0 : window.pageXOffset)) {
transformOriginY = 'right';
}
transformOrigin.value = `${transformOriginX} ${transformOriginY}`;
popover.style.left = left + 'px';
popover.style.top = top + 'px';
};

View file

@ -3,6 +3,7 @@ import { defaultStore } from '@/store';
export default {
mounted(el, binding, vn) {
/*
if (!defaultStore.state.animation) return;
el.classList.add('_anime_bounce_standBy');
@ -25,5 +26,6 @@ export default {
el.classList.remove('_anime_bounce');
el.classList.add('_anime_bounce_standBy');
});
*/
}
} as Directive;

View file

@ -10,37 +10,6 @@
<template #label>{{ $ts.instanceDescription }}</template>
</FormTextarea>
<FormInput v-model="iconUrl" class="_formBlock">
<template #prefix><i class="fas fa-link"></i></template>
<template #label>{{ $ts.iconUrl }}</template>
</FormInput>
<FormInput v-model="bannerUrl" class="_formBlock">
<template #prefix><i class="fas fa-link"></i></template>
<template #label>{{ $ts.bannerUrl }}</template>
</FormInput>
<FormInput v-model="backgroundImageUrl" class="_formBlock">
<template #prefix><i class="fas fa-link"></i></template>
<template #label>{{ $ts.backgroundImageUrl }}</template>
</FormInput>
<FormInput v-model="themeColor" class="_formBlock">
<template #prefix><i class="fas fa-palette"></i></template>
<template #label>{{ $ts.themeColor }}</template>
<template #caption>#RRGGBB</template>
</FormInput>
<FormTextarea v-model="defaultLightTheme" class="_formBlock">
<template #label>{{ $ts.instanceDefaultLightTheme }}</template>
<template #caption>{{ $ts.instanceDefaultThemeDescription }}</template>
</FormTextarea>
<FormTextarea v-model="defaultDarkTheme" class="_formBlock">
<template #label>{{ $ts.instanceDefaultDarkTheme }}</template>
<template #caption>{{ $ts.instanceDefaultThemeDescription }}</template>
</FormTextarea>
<FormInput v-model="tosUrl" class="_formBlock">
<template #prefix><i class="fas fa-link"></i></template>
<template #label>{{ $ts.tosUrl }}</template>
@ -78,6 +47,41 @@
<FormInfo class="_formBlock">{{ $ts.disablingTimelinesInfo }}</FormInfo>
</FormSection>
<FormSection>
<template #label>{{ $ts.theme }}</template>
<FormInput v-model="iconUrl" class="_formBlock">
<template #prefix><i class="fas fa-link"></i></template>
<template #label>{{ $ts.iconUrl }}</template>
</FormInput>
<FormInput v-model="bannerUrl" class="_formBlock">
<template #prefix><i class="fas fa-link"></i></template>
<template #label>{{ $ts.bannerUrl }}</template>
</FormInput>
<FormInput v-model="backgroundImageUrl" class="_formBlock">
<template #prefix><i class="fas fa-link"></i></template>
<template #label>{{ $ts.backgroundImageUrl }}</template>
</FormInput>
<FormInput v-model="themeColor" class="_formBlock">
<template #prefix><i class="fas fa-palette"></i></template>
<template #label>{{ $ts.themeColor }}</template>
<template #caption>#RRGGBB</template>
</FormInput>
<FormTextarea v-model="defaultLightTheme" class="_formBlock">
<template #label>{{ $ts.instanceDefaultLightTheme }}</template>
<template #caption>{{ $ts.instanceDefaultThemeDescription }}</template>
</FormTextarea>
<FormTextarea v-model="defaultDarkTheme" class="_formBlock">
<template #label>{{ $ts.instanceDefaultDarkTheme }}</template>
<template #caption>{{ $ts.instanceDefaultThemeDescription }}</template>
</FormTextarea>
</FormSection>
<FormSection>
<template #label>{{ $ts.files }}</template>

View file

@ -33,10 +33,6 @@
<option value="-caughtAt">{{ $ts.registeredAt }} ({{ $ts.ascendingOrder }})</option>
<option value="+lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.descendingOrder }})</option>
<option value="-lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.ascendingOrder }})</option>
<option value="+driveUsage">{{ $ts.driveUsage }} ({{ $ts.descendingOrder }})</option>
<option value="-driveUsage">{{ $ts.driveUsage }} ({{ $ts.ascendingOrder }})</option>
<option value="+driveFiles">{{ $ts.driveFilesCount }} ({{ $ts.descendingOrder }})</option>
<option value="-driveFiles">{{ $ts.driveFilesCount }} ({{ $ts.ascendingOrder }})</option>
</MkSelect>
</FormSplit>
</div>

View file

@ -22,8 +22,8 @@
</MkSpacer>
</template>
<script lang="ts">
import { computed, defineAsyncComponent, defineComponent, nextTick, onMounted, reactive, ref, watch } from 'vue';
<script setup lang="ts">
import { computed, defineAsyncComponent, nextTick, onMounted, ref, watch } from 'vue';
import { i18n } from '@/i18n';
import MkInfo from '@/components/ui/info.vue';
import MkSuperMenu from '@/components/ui/super-menu.vue';
@ -34,241 +34,219 @@ import * as symbols from '@/symbols';
import { instance } from '@/instance';
import { $i } from '@/account';
export default defineComponent({
components: {
MkInfo,
MkSuperMenu,
},
const props = defineProps<{
initialPage?: string
}>();
props: {
initialPage: {
type: String,
required: false
const indexInfo = {
title: i18n.ts.settings,
icon: 'fas fa-cog',
bg: 'var(--bg)',
hideHeader: true,
};
const INFO = ref(indexInfo);
const page = ref(props.initialPage);
const narrow = ref(false);
const view = ref(null);
const el = ref<HTMLElement | null>(null);
const childInfo = ref(null);
const menuDef = computed(() => [{
title: i18n.ts.basicSettings,
items: [{
icon: 'fas fa-user',
text: i18n.ts.profile,
to: '/settings/profile',
active: page.value === 'profile',
}, {
icon: 'fas fa-lock-open',
text: i18n.ts.privacy,
to: '/settings/privacy',
active: page.value === 'privacy',
}, {
icon: 'fas fa-laugh',
text: i18n.ts.reaction,
to: '/settings/reaction',
active: page.value === 'reaction',
}, {
icon: 'fas fa-cloud',
text: i18n.ts.drive,
to: '/settings/drive',
active: page.value === 'drive',
}, {
icon: 'fas fa-bell',
text: i18n.ts.notifications,
to: '/settings/notifications',
active: page.value === 'notifications',
}, {
icon: 'fas fa-envelope',
text: i18n.ts.email,
to: '/settings/email',
active: page.value === 'email',
}, {
icon: 'fas fa-share-alt',
text: i18n.ts.integration,
to: '/settings/integration',
active: page.value === 'integration',
}, {
icon: 'fas fa-lock',
text: i18n.ts.security,
to: '/settings/security',
active: page.value === 'security',
}],
}, {
title: i18n.ts.clientSettings,
items: [{
icon: 'fas fa-cogs',
text: i18n.ts.general,
to: '/settings/general',
active: page.value === 'general',
}, {
icon: 'fas fa-palette',
text: i18n.ts.theme,
to: '/settings/theme',
active: page.value === 'theme',
}, {
icon: 'fas fa-list-ul',
text: i18n.ts.menu,
to: '/settings/menu',
active: page.value === 'menu',
}, {
icon: 'fas fa-music',
text: i18n.ts.sounds,
to: '/settings/sounds',
active: page.value === 'sounds',
}, {
icon: 'fas fa-plug',
text: i18n.ts.plugins,
to: '/settings/plugin',
active: page.value === 'plugin',
}],
}, {
title: i18n.ts.otherSettings,
items: [{
icon: 'fas fa-boxes',
text: i18n.ts.importAndExport,
to: '/settings/import-export',
active: page.value === 'import-export',
}, {
icon: 'fas fa-volume-mute',
text: i18n.ts.instanceMute,
to: '/settings/instance-mute',
active: page.value === 'instance-mute',
}, {
icon: 'fas fa-ban',
text: i18n.ts.muteAndBlock,
to: '/settings/mute-block',
active: page.value === 'mute-block',
}, {
icon: 'fas fa-comment-slash',
text: i18n.ts.wordMute,
to: '/settings/word-mute',
active: page.value === 'word-mute',
}, {
icon: 'fas fa-key',
text: 'API',
to: '/settings/api',
active: page.value === 'api',
}, {
icon: 'fas fa-ellipsis-h',
text: i18n.ts.other,
to: '/settings/other',
active: page.value === 'other',
}],
}, {
items: [{
type: 'button',
icon: 'fas fa-trash',
text: i18n.ts.clearCache,
action: () => {
localStorage.removeItem('locale');
localStorage.removeItem('theme');
unisonReload();
},
}, {
type: 'button',
icon: 'fas fa-sign-in-alt fa-flip-horizontal',
text: i18n.ts.logout,
action: () => {
signout();
},
danger: true,
},],
}]);
const pageProps = ref({});
const component = computed(() => {
if (page.value == null) return null;
switch (page.value) {
case 'accounts': return defineAsyncComponent(() => import('./accounts.vue'));
case 'profile': return defineAsyncComponent(() => import('./profile.vue'));
case 'privacy': return defineAsyncComponent(() => import('./privacy.vue'));
case 'reaction': return defineAsyncComponent(() => import('./reaction.vue'));
case 'drive': return defineAsyncComponent(() => import('./drive.vue'));
case 'notifications': return defineAsyncComponent(() => import('./notifications.vue'));
case 'mute-block': return defineAsyncComponent(() => import('./mute-block.vue'));
case 'word-mute': return defineAsyncComponent(() => import('./word-mute.vue'));
case 'instance-mute': return defineAsyncComponent(() => import('./instance-mute.vue'));
case 'integration': return defineAsyncComponent(() => import('./integration.vue'));
case 'security': return defineAsyncComponent(() => import('./security.vue'));
case '2fa': return defineAsyncComponent(() => import('./2fa.vue'));
case 'api': return defineAsyncComponent(() => import('./api.vue'));
case 'apps': return defineAsyncComponent(() => import('./apps.vue'));
case 'other': return defineAsyncComponent(() => import('./other.vue'));
case 'general': return defineAsyncComponent(() => import('./general.vue'));
case 'email': return defineAsyncComponent(() => import('./email.vue'));
case 'theme': return defineAsyncComponent(() => import('./theme.vue'));
case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue'));
case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue'));
case 'menu': return defineAsyncComponent(() => import('./menu.vue'));
case 'sounds': return defineAsyncComponent(() => import('./sounds.vue'));
case 'custom-css': return defineAsyncComponent(() => import('./custom-css.vue'));
case 'deck': return defineAsyncComponent(() => import('./deck.vue'));
case 'plugin': return defineAsyncComponent(() => import('./plugin.vue'));
case 'plugin/install': return defineAsyncComponent(() => import('./plugin.install.vue'));
case 'import-export': return defineAsyncComponent(() => import('./import-export.vue'));
case 'account-info': return defineAsyncComponent(() => import('./account-info.vue'));
case 'delete-account': return defineAsyncComponent(() => import('./delete-account.vue'));
}
return null;
});
watch(component, () => {
pageProps.value = {};
nextTick(() => {
scroll(el.value, { top: 0 });
});
}, { immediate: true });
watch(() => props.initialPage, () => {
if (props.initialPage == null && !narrow.value) {
page.value = 'profile';
} else {
page.value = props.initialPage;
if (props.initialPage == null) {
INFO.value = indexInfo;
}
},
}
});
setup(props, context) {
const indexInfo = {
title: i18n.ts.settings,
icon: 'fas fa-cog',
bg: 'var(--bg)',
hideHeader: true,
};
const INFO = ref(indexInfo);
const page = ref(props.initialPage);
const narrow = ref(false);
const view = ref(null);
const el = ref(null);
const childInfo = ref(null);
const menuDef = computed(() => [{
title: i18n.ts.basicSettings,
items: [{
icon: 'fas fa-user',
text: i18n.ts.profile,
to: '/settings/profile',
active: page.value === 'profile',
}, {
icon: 'fas fa-lock-open',
text: i18n.ts.privacy,
to: '/settings/privacy',
active: page.value === 'privacy',
}, {
icon: 'fas fa-laugh',
text: i18n.ts.reaction,
to: '/settings/reaction',
active: page.value === 'reaction',
}, {
icon: 'fas fa-cloud',
text: i18n.ts.drive,
to: '/settings/drive',
active: page.value === 'drive',
}, {
icon: 'fas fa-bell',
text: i18n.ts.notifications,
to: '/settings/notifications',
active: page.value === 'notifications',
}, {
icon: 'fas fa-envelope',
text: i18n.ts.email,
to: '/settings/email',
active: page.value === 'email',
}, {
icon: 'fas fa-share-alt',
text: i18n.ts.integration,
to: '/settings/integration',
active: page.value === 'integration',
}, {
icon: 'fas fa-lock',
text: i18n.ts.security,
to: '/settings/security',
active: page.value === 'security',
}],
}, {
title: i18n.ts.clientSettings,
items: [{
icon: 'fas fa-cogs',
text: i18n.ts.general,
to: '/settings/general',
active: page.value === 'general',
}, {
icon: 'fas fa-palette',
text: i18n.ts.theme,
to: '/settings/theme',
active: page.value === 'theme',
}, {
icon: 'fas fa-list-ul',
text: i18n.ts.menu,
to: '/settings/menu',
active: page.value === 'menu',
}, {
icon: 'fas fa-music',
text: i18n.ts.sounds,
to: '/settings/sounds',
active: page.value === 'sounds',
}, {
icon: 'fas fa-plug',
text: i18n.ts.plugins,
to: '/settings/plugin',
active: page.value === 'plugin',
}],
}, {
title: i18n.ts.otherSettings,
items: [{
icon: 'fas fa-boxes',
text: i18n.ts.importAndExport,
to: '/settings/import-export',
active: page.value === 'import-export',
}, {
icon: 'fas fa-volume-mute',
text: i18n.ts.instanceMute,
to: '/settings/instance-mute',
active: page.value === 'instance-mute',
}, {
icon: 'fas fa-ban',
text: i18n.ts.muteAndBlock,
to: '/settings/mute-block',
active: page.value === 'mute-block',
}, {
icon: 'fas fa-comment-slash',
text: i18n.ts.wordMute,
to: '/settings/word-mute',
active: page.value === 'word-mute',
}, {
icon: 'fas fa-key',
text: 'API',
to: '/settings/api',
active: page.value === 'api',
}, {
icon: 'fas fa-ellipsis-h',
text: i18n.ts.other,
to: '/settings/other',
active: page.value === 'other',
}],
}, {
items: [{
type: 'button',
icon: 'fas fa-trash',
text: i18n.ts.clearCache,
action: () => {
localStorage.removeItem('locale');
localStorage.removeItem('theme');
unisonReload();
},
}, {
type: 'button',
icon: 'fas fa-sign-in-alt fa-flip-horizontal',
text: i18n.ts.logout,
action: () => {
signout();
},
danger: true,
},],
}]);
onMounted(() => {
narrow.value = el.value.offsetWidth < 800;
if (!narrow.value) {
page.value = 'profile';
}
});
const pageProps = ref({});
const component = computed(() => {
if (page.value == null) return null;
switch (page.value) {
case 'accounts': return defineAsyncComponent(() => import('./accounts.vue'));
case 'profile': return defineAsyncComponent(() => import('./profile.vue'));
case 'privacy': return defineAsyncComponent(() => import('./privacy.vue'));
case 'reaction': return defineAsyncComponent(() => import('./reaction.vue'));
case 'drive': return defineAsyncComponent(() => import('./drive.vue'));
case 'notifications': return defineAsyncComponent(() => import('./notifications.vue'));
case 'mute-block': return defineAsyncComponent(() => import('./mute-block.vue'));
case 'word-mute': return defineAsyncComponent(() => import('./word-mute.vue'));
case 'instance-mute': return defineAsyncComponent(() => import('./instance-mute.vue'));
case 'integration': return defineAsyncComponent(() => import('./integration.vue'));
case 'security': return defineAsyncComponent(() => import('./security.vue'));
case '2fa': return defineAsyncComponent(() => import('./2fa.vue'));
case 'api': return defineAsyncComponent(() => import('./api.vue'));
case 'apps': return defineAsyncComponent(() => import('./apps.vue'));
case 'other': return defineAsyncComponent(() => import('./other.vue'));
case 'general': return defineAsyncComponent(() => import('./general.vue'));
case 'email': return defineAsyncComponent(() => import('./email.vue'));
case 'theme': return defineAsyncComponent(() => import('./theme.vue'));
case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue'));
case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue'));
case 'menu': return defineAsyncComponent(() => import('./menu.vue'));
case 'sounds': return defineAsyncComponent(() => import('./sounds.vue'));
case 'custom-css': return defineAsyncComponent(() => import('./custom-css.vue'));
case 'deck': return defineAsyncComponent(() => import('./deck.vue'));
case 'plugin': return defineAsyncComponent(() => import('./plugin.vue'));
case 'plugin/install': return defineAsyncComponent(() => import('./plugin.install.vue'));
case 'import-export': return defineAsyncComponent(() => import('./import-export.vue'));
case 'account-info': return defineAsyncComponent(() => import('./account-info.vue'));
case 'delete-account': return defineAsyncComponent(() => import('./delete-account.vue'));
}
return null;
});
const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified));
watch(component, () => {
pageProps.value = {};
const pageChanged = (page) => {
if (page == null) return;
childInfo.value = page[symbols.PAGE_INFO];
};
nextTick(() => {
scroll(el.value, { top: 0 });
});
}, { immediate: true });
watch(() => props.initialPage, () => {
if (props.initialPage == null && !narrow.value) {
page.value = 'profile';
} else {
page.value = props.initialPage;
if (props.initialPage == null) {
INFO.value = indexInfo;
}
}
});
onMounted(() => {
narrow.value = el.value.offsetWidth < 800;
if (!narrow.value) {
page.value = 'profile';
}
});
const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified));
const pageChanged = (page) => {
if (page == null) return;
childInfo.value = page[symbols.PAGE_INFO];
};
return {
[symbols.PAGE_INFO]: INFO,
page,
menuDef,
narrow,
view,
el,
pageProps,
component,
emailNotConfigured,
pageChanged,
childInfo,
};
},
defineExpose({
[symbols.PAGE_INFO]: INFO,
});
</script>

View file

@ -4,7 +4,9 @@
{{ $ts.showFeaturedNotesInTimeline }}
</FormSwitch>
<!--
<FormSwitch v-model="reportError" class="_formBlock">{{ $ts.sendErrorReports }}<template #caption>{{ $ts.sendErrorReportsDescription }}</template></FormSwitch>
-->
<FormLink to="/settings/account-info" class="_formBlock">{{ $ts.accountInfo }}</FormLink>

View file

@ -101,7 +101,7 @@ import { ColdDeviceStorage } from '@/store';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
import { instance } from '@/instance';
import { concat } from '@/scripts/array';
import { concat, uniqueBy } from '@/scripts/array';
import { fetchThemes, getThemes } from '@/theme-store';
import * as symbols from '@/symbols';
@ -128,7 +128,7 @@ export default defineComponent({
const instanceThemes = [];
if (instance.defaultLightTheme != null) instanceThemes.push(JSON5.parse(instance.defaultLightTheme));
if (instance.defaultDarkTheme != null) instanceThemes.push(JSON5.parse(instance.defaultDarkTheme));
const themes = computed(() => instanceThemes.concat(builtinThemes.concat(installedThemes.value)));
const themes = computed(() => uniqueBy(instanceThemes.concat(builtinThemes.concat(installedThemes.value)), theme => theme.id));
const darkThemes = computed(() => themes.value.filter(t => t.base === 'dark' || t.kind === 'dark'));
const lightThemes = computed(() => themes.value.filter(t => t.base === 'light' || t.kind === 'light'));
const darkTheme = ColdDeviceStorage.ref('darkTheme');

View file

@ -3,7 +3,7 @@ import { createRouter, createWebHistory } from 'vue-router';
import MkLoading from '@/pages/_loading_.vue';
import MkError from '@/pages/_error_.vue';
import MkTimeline from '@/pages/timeline.vue';
import { $i } from './account';
import { $i, iAmModerator } from './account';
import { ui } from '@/config';
const page = (path: string, ui?: string) => defineAsyncComponent({
@ -67,8 +67,8 @@ const defaultRoutes = [
{ path: '/my/antennas/:antennaId', component: page('my-antennas/edit'), props: true },
{ path: '/my/clips', component: page('my-clips/index') },
{ path: '/scratchpad', component: page('scratchpad') },
{ path: '/admin/:page(.*)?', component: page('admin/index'), props: route => ({ initialPage: route.params.page || null }) },
{ path: '/admin', component: page('admin/index') },
{ path: '/admin/:page(.*)?', component: iAmModerator ? page('admin/index') : page('not-found'), props: route => ({ initialPage: route.params.page || null }) },
{ path: '/admin', component: iAmModerator ? page('admin/index') : page('not-found') },
{ path: '/notes/:note', name: 'note', component: page('note'), props: route => ({ noteId: route.params.note }) },
{ path: '/tags/:tag', component: page('tag'), props: route => ({ tag: route.params.tag }) },
{ path: '/user-info/:user', component: page('user-info'), props: route => ({ userId: route.params.user }) },

View file

@ -52,6 +52,17 @@ export function unique<T>(xs: T[]): T[] {
return [...new Set(xs)];
}
export function uniqueBy<TValue, TKey>(values: TValue[], keySelector: (value: TValue) => TKey): TValue[] {
const map = new Map<TKey, TValue>();
for (const value of values) {
const key = keySelector(value);
if (!map.has(key)) map.set(key, value);
}
return [...map.values()];
}
export function sum(xs: number[]): number {
return xs.reduce((a, b) => a + b, 0);
}

View file

@ -56,11 +56,44 @@ export function getUserMenu(user) {
}
async function toggleMute() {
os.apiWithDialog(user.isMuted ? 'mute/delete' : 'mute/create', {
userId: user.id
}).then(() => {
user.isMuted = !user.isMuted;
});
if (user.isMuted) {
os.apiWithDialog('mute/delete', {
userId: user.id,
}).then(() => {
user.isMuted = false;
});
} else {
const { canceled, result: period } = await os.select({
title: i18n.ts.mutePeriod,
items: [{
value: 'indefinitely', text: i18n.ts.indefinitely,
}, {
value: 'tenMinutes', text: i18n.ts.tenMinutes,
}, {
value: 'oneHour', text: i18n.ts.oneHour,
}, {
value: 'oneDay', text: i18n.ts.oneDay,
}, {
value: 'oneWeek', text: i18n.ts.oneWeek,
}],
default: 'indefinitely',
});
if (canceled) return;
const expiresAt = period === 'indefinitely' ? null
: period === 'tenMinutes' ? Date.now() + (1000 * 60 * 10)
: period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
: period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
: period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
: null;
os.apiWithDialog('mute/create', {
userId: user.id,
expiresAt,
}).then(() => {
user.isMuted = true;
});
}
}
async function toggleBlock() {

View file

@ -68,11 +68,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'deviceAccount',
default: [
'notifications',
'messaging',
'favorites',
'drive',
'followRequests',
'-',
'gallery',
'featured',
'explore',
'announcements',
@ -192,7 +191,7 @@ export const defaultStore = markRaw(new Storage('base', {
},
reactionPickerHeight: {
where: 'device',
default: 1
default: 2
},
reactionPickerUseDrawerForMobile: {
where: 'device',

View file

@ -113,6 +113,7 @@ export default defineComponent({
padding: 4px 5px;
font-size: 14px;
pointer-events: none;
user-select: none;
> span {
animation: dev-ticker-blink 2s infinite;

View file

@ -265,7 +265,7 @@ const wallpaper = localStorage.getItem('wallpaper') != null;
min-width: 0;
> .spacer {
height: 82px;
height: calc(env(safe-area-inset-bottom, 0px) + 96px);
@media (min-width: ($widgets-hide-threshold + 1px)) {
display: none;