diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-02-11 14:08:58 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2023-02-11 14:08:58 +0900 |
| commit | 5af8b77d287f006031238293c29d8d5cea1cd4a1 (patch) | |
| tree | 75369017b1091e21c3072d0adff062cdc510b701 /packages/frontend/src | |
| parent | Merge branch 'develop' (diff) | |
| parent | 13.6.0 (diff) | |
| download | misskey-5af8b77d287f006031238293c29d8d5cea1cd4a1.tar.gz misskey-5af8b77d287f006031238293c29d8d5cea1cd4a1.tar.bz2 misskey-5af8b77d287f006031238293c29d8d5cea1cd4a1.zip | |
Merge branch 'develop'
Diffstat (limited to 'packages/frontend/src')
26 files changed, 498 insertions, 526 deletions
diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index da4db63406..9690353432 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -32,7 +32,7 @@ <MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton> </div> <div v-if="actions" :class="$style.buttons"> - <MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" @click="() => { action.callback(); close(); }">{{ action.text }}</MkButton> + <MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton> </div> </div> </MkModal> diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index 6ac56f3ce0..b777a1329b 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -8,7 +8,7 @@ </div> </div> </div> -<div v-else :class="$style.visible"> +<div v-else :class="$style.visible" :style="defaultStore.state.darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'"> <a :class="$style.imageContainer" :href="image.url" @@ -78,7 +78,6 @@ watch(() => props.image, () => { position: relative; //box-shadow: 0 0 0 1px var(--divider) inset; background: var(--bg); - --c: rgb(0 0 0 / 2%); background-image: linear-gradient(45deg, var(--c) 16.67%, var(--bg) 16.67%, var(--bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--bg) 66.67%, var(--bg) 100%); background-size: 16px 16px; } diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 7edcaf1324..e910fbab01 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -5,14 +5,14 @@ ref="el" v-hotkey="keymap" :class="$style.root" - :tabindex="!isDeleted ? '-1' : null" + :tabindex="!isDeleted ? '-1' : undefined" > - <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :class="$style.replyTo"/> + <MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/> <div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div> <!--<div v-if="appearNote._prId_" class="tip"><i class="fas fa-bullhorn"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>--> <!--<div v-if="appearNote._featuredId_" class="tip"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>--> <div v-if="isRenote" :class="$style.renote"> - <MkAvatar v-once :class="$style.renoteAvatar" :user="note.user" link preview/> + <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/> <i class="ti ti-repeat" style="margin-right: 4px;"></i> <I18n :src="i18n.ts.renotedBy" tag="span" :class="$style.renoteText"> <template #user> @@ -34,8 +34,12 @@ <span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['localOnly']"><i class="ti ti-world-off"></i></span> </div> </div> - <article :class="$style.article" @contextmenu.stop="onContextmenu"> - <MkAvatar v-once :class="$style.avatar" :user="appearNote.user" link preview/> + <div v-if="renoteCollapsed" :class="$style.collapsedRenoteTarget"> + <MkAvatar :class="$style.collapsedRenoteTargetAvatar" :user="appearNote.user" link preview/> + <Mfm :text="getNoteSummary(appearNote)" :plain="true" :nowrap="true" :author="appearNote.user" :class="$style.collapsedRenoteTargetText" @click="renoteCollapsed = false"/> + </div> + <article v-else :class="$style.article" @contextmenu.stop="onContextmenu"> + <MkAvatar :class="$style.avatar" :user="appearNote.user" link preview/> <div :class="$style.main"> <MkNoteHeader :class="$style.header" :note="appearNote" :mini="true"/> <MkInstanceTicker v-if="showTicker" :class="$style.ticker" :instance="appearNote.user.instance"/> @@ -60,7 +64,7 @@ <div v-if="appearNote.files.length > 0" :class="$style.files"> <MkMediaList :media-list="appearNote.files"/> </div> - <MkPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" :class="$style.poll"/> + <MkPoll v-if="appearNote.poll" :note="appearNote" :class="$style.poll"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/> <div v-if="appearNote.renote" :class="$style.quote"><MkNoteSimple :note="appearNote.renote" :class="$style.quoteNote"/></div> <button v-if="isLong && collapsed" :class="$style.collapsed" class="_button" @click="collapsed = false"> @@ -73,7 +77,13 @@ <MkA v-if="appearNote.channel && !inChannel" :class="$style.channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA> </div> <footer :class="$style.footer"> - <MkReactionsViewer ref="reactionsViewer" :note="appearNote"/> + <MkReactionsViewer :note="appearNote" :max-number="16"> + <template v-slot:more> + <button class="_button" :class="$style.reactionDetailsButton" @click="showReactions"> + {{ i18n.ts.more }} + </button> + </template> + </MkReactionsViewer> <button :class="$style.footerButton" class="_button" @click="reply()"> <i class="ti ti-arrow-back-up"></i> <p v-if="appearNote.repliesCount > 0" :class="$style.footerButtonCount">{{ appearNote.repliesCount }}</p> @@ -116,7 +126,7 @@ </template> <script lang="ts" setup> -import { computed, inject, onMounted, onUnmounted, reactive, ref, shallowRef, Ref } from 'vue'; +import { computed, inject, onMounted, onUnmounted, reactive, ref, shallowRef, Ref, defineAsyncComponent } from 'vue'; import * as mfm from 'mfm-js'; import * as misskey from 'misskey-js'; import MkNoteSub from '@/components/MkNoteSub.vue'; @@ -144,6 +154,8 @@ import { useNoteCapture } from '@/scripts/use-note-capture'; import { deepClone } from '@/scripts/clone'; import { useTooltip } from '@/scripts/use-tooltip'; import { claimAchievement } from '@/scripts/achievements'; +import { getNoteSummary } from '@/scripts/get-note-summary'; +import { shownNoteIds } from '@/os'; const props = defineProps<{ note: misskey.entities.Note; @@ -180,18 +192,23 @@ const reactButton = shallowRef<HTMLElement>(); let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note); const isMyRenote = $i && ($i.id === note.userId); const showContent = ref(false); +const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null; const isLong = (appearNote.cw == null && appearNote.text != null && ( (appearNote.text.split('\n').length > 9) || - (appearNote.text.length > 500) + (appearNote.text.length > 500) || + (appearNote.files.length >= 5) || + (urls && urls.length >= 4) )); const collapsed = ref(appearNote.cw == null && isLong); const isDeleted = ref(false); const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords)); -const translation = ref(null); +const translation = ref<any>(null); const translating = ref(false); -const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null; const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance); const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id); +let renoteCollapsed = $ref(isRenote && (($i && ($i.id === note.userId)) || shownNoteIds.has(appearNote.id))); + +shownNoteIds.add(appearNote.id); const keymap = { 'r': () => reply(true), @@ -350,6 +367,12 @@ function readPromo() { }); isDeleted.value = true; } + +function showReactions(): void { + os.popup(defineAsyncComponent(() => import('@/components/MkReactedUsersDialog.vue')), { + noteId: appearNote.id, + }, {}, 'closed'); +} </script> <style lang="scss" module> @@ -433,7 +456,6 @@ function readPromo() { width: 28px; height: 28px; margin: 0 8px 0 0; - border-radius: 6px; } .renoteText { @@ -461,6 +483,36 @@ function readPromo() { margin-right: 4px; } +.collapsedRenoteTarget { + display: flex; + align-items: center; + line-height: 28px; + white-space: pre; + padding: 0 32px 18px; +} + +.collapsedRenoteTargetAvatar { + flex-shrink: 0; + display: inline-block; + width: 28px; + height: 28px; + margin: 0 8px 0 0; +} + +.collapsedRenoteTargetText { + overflow: hidden; + flex-shrink: 1; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 90%; + opacity: 0.7; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} + .article { display: flex; padding: 28px 32px 18px; @@ -614,6 +666,11 @@ function readPromo() { padding: 8px 16px 0 16px; } + .collapsedRenoteTarget { + padding: 0 16px 9px; + margin-top: 4px; + } + .article { padding: 14px 16px 9px; } @@ -652,4 +709,19 @@ function readPromo() { text-align: center; opacity: 0.7; } + +.reactionDetailsButton { + display: inline-block; + height: 32px; + margin: 2px; + padding: 0 6px; + border: dashed 1px var(--divider); + border-radius: 4px; + background: transparent; + opacity: .8; + + &:hover { + background: var(--X5); + } +} </style> diff --git a/packages/frontend/src/components/MkNoteHeader.vue b/packages/frontend/src/components/MkNoteHeader.vue index 6b43f14665..32998e1a70 100644 --- a/packages/frontend/src/components/MkNoteHeader.vue +++ b/packages/frontend/src/components/MkNoteHeader.vue @@ -1,6 +1,6 @@ <template> <header :class="$style.root"> - <MkA v-once v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)"> + <MkA v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)"> <MkUserName :user="note.user"/> </MkA> <div v-if="note.user.isBot" :class="$style.isBot">bot</div> @@ -90,7 +90,7 @@ defineProps<{ vertical-align: -20%; & + .badgeRole { - margin-left: .125em; + margin-left: 0.2em; } } </style> diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 8c7114eac1..0d42e8ffbf 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -1,6 +1,6 @@ <template> <div ref="elRef" :class="$style.root"> - <div v-once :class="$style.head"> + <div :class="$style.head"> <MkAvatar v-if="notification.type === 'pollEnded'" :class="$style.icon" :user="notification.note.user" link preview/> <MkAvatar v-else-if="notification.type === 'achievementEarned'" :class="$style.icon" :user="$i" link preview/> <MkAvatar v-else-if="notification.user" :class="$style.icon" :user="notification.user" link preview/> @@ -35,7 +35,7 @@ <span v-else>{{ notification.header }}</span> <MkTime v-if="withTime" :time="notification.createdAt" :class="$style.headerTime"/> </header> - <div v-once :class="$style.content"> + <div :class="$style.content"> <MkA v-if="notification.type === 'reaction'" :class="$style.text" :to="notePage(notification.note)" :title="getNoteSummary(notification.note)"> <i class="ti ti-quote" :class="$style.quote"></i> <Mfm :text="getNoteSummary(notification.note)" :plain="true" :nowrap="true" :author="notification.note.user"/> diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index c7e7e85b2e..f15906c1c1 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -109,7 +109,7 @@ const props = withDefaults(defineProps<{ mention?: misskey.entities.User; specified?: misskey.entities.User; initialText?: string; - initialVisibility?: typeof misskey.noteVisibilities; + initialVisibility?: (typeof misskey.noteVisibilities)[number]; initialFiles?: misskey.entities.DriveFile[]; initialLocalOnly?: boolean; initialVisibleUsers?: misskey.entities.User[]; @@ -579,6 +579,36 @@ async function post(ev?: MouseEvent) { os.popup(MkRippleEffect, { x, y }, {}, 'end'); } + const annoying = + text.includes('$[x2') || + text.includes('$[x3') || + text.includes('$[x4') || + text.includes('$[scale') || + text.includes('$[position'); + if (annoying) { + const { canceled, result } = await os.actions({ + type: 'warning', + text: i18n.ts.thisPostMayBeAnnoying, + actions: [{ + value: 'home', + text: i18n.ts.thisPostMayBeAnnoyingHome, + primary: true, + }, { + value: 'cancel', + text: i18n.ts.thisPostMayBeAnnoyingCancel, + }, { + value: 'ignore', + text: i18n.ts.thisPostMayBeAnnoyingIgnore, + }], + }); + + if (canceled) return; + if (result === 'cancel') return; + if (result === 'home') { + visibility = 'home'; + } + } + let postData = { text: text === '' ? undefined : text, fileIds: files.length > 0 ? files.map(f => f.id) : undefined, diff --git a/packages/frontend/src/components/MkReactionsViewer.reaction.vue b/packages/frontend/src/components/MkReactionsViewer.reaction.vue index 83fdf0f988..4abd2562df 100644 --- a/packages/frontend/src/components/MkReactionsViewer.reaction.vue +++ b/packages/frontend/src/components/MkReactionsViewer.reaction.vue @@ -107,7 +107,7 @@ useTooltip(buttonEl, async (showing) => { border-radius: 4px; &.canToggle { - background: rgba(0, 0, 0, 0.05); + background: var(--buttonBg); &:hover { background: rgba(0, 0, 0, 0.1); diff --git a/packages/frontend/src/components/MkReactionsViewer.vue b/packages/frontend/src/components/MkReactionsViewer.vue index 5981471c68..cdd6f528e7 100644 --- a/packages/frontend/src/components/MkReactionsViewer.vue +++ b/packages/frontend/src/components/MkReactionsViewer.vue @@ -7,23 +7,60 @@ :move-class="$store.state.animation ? $style.transition_x_move : ''" tag="div" :class="$style.root" > - <XReaction v-for="(count, reaction) in note.reactions" :key="reaction" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note"/> + <XReaction v-for="[reaction, count] in reactions" :key="reaction" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note"/> + <slot v-if="hasMoreReactions" name="more" /> </TransitionGroup> </template> <script lang="ts" setup> -import { computed } from 'vue'; import * as misskey from 'misskey-js'; -import { $i } from '@/account'; import XReaction from '@/components/MkReactionsViewer.reaction.vue'; +import { watch } from 'vue'; -const props = defineProps<{ - note: misskey.entities.Note; -}>(); +const props = withDefaults(defineProps<{ + note: misskey.entities.Note; + maxNumber?: number; +}>(), { + maxNumber: Infinity, +}); const initialReactions = new Set(Object.keys(props.note.reactions)); -const isMe = computed(() => $i && $i.id === props.note.userId); +let reactions = $ref<[string, number][]>([]); +let hasMoreReactions = $ref(false); + +if (props.note.myReaction && !Object.keys(reactions).includes(props.note.myReaction)) { + reactions[props.note.myReaction] = props.note.reactions[props.note.myReaction]; +} + +watch([() => props.note.reactions, () => props.maxNumber], ([newSource, maxNumber]) => { + let newReactions: [string, number][] = []; + hasMoreReactions = Object.keys(newSource).length > maxNumber; + + for (let i = 0; i < reactions.length; i++) { + const reaction = reactions[i][0]; + if (reaction in newSource && newSource[reaction] !== 0) { + reactions[i][1] = newSource[reaction]; + newReactions.push(reactions[i]); + } + } + + const newReactionsNames = newReactions.map(([x]) => x); + newReactions = [ + ...newReactions, + ...Object.entries(newSource) + .sort(([, a], [, b]) => b - a) + .filter(([y], i) => i < maxNumber && !newReactionsNames.includes(y)), + ] + + newReactions = newReactions.slice(0, props.maxNumber); + + if (props.note.myReaction && !newReactions.map(([x]) => x).includes(props.note.myReaction)) { + newReactions.push([props.note.myReaction, newSource[props.note.myReaction]]); + } + + reactions = newReactions; +}, { immediate: true, deep: true }); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkUserPreview.vue b/packages/frontend/src/components/MkUserPreview.vue index f68fdd64d9..eacc66de4f 100644 --- a/packages/frontend/src/components/MkUserPreview.vue +++ b/packages/frontend/src/components/MkUserPreview.vue @@ -24,6 +24,7 @@ <p>{{ $ts.followers }}</p><span>{{ user.followersCount }}</span> </div> </div> + <button class="menu _button" @click="showMenu"><i class="ti ti-dots"></i></button> <MkFollowButton v-if="$i && user.id != $i.id" class="koudoku-button" :user="user" mini/> </div> <div v-else> @@ -40,6 +41,7 @@ import * as misskey from 'misskey-js'; import MkFollowButton from '@/components/MkFollowButton.vue'; import { userPage } from '@/filters/user'; import * as os from '@/os'; +import { getUserMenu } from '@/scripts/get-user-menu'; const props = defineProps<{ showing: boolean; @@ -58,6 +60,10 @@ let user = $ref<misskey.entities.UserDetailed | null>(null); let top = $ref(0); let left = $ref(0); +function showMenu(ev: MouseEvent) { + os.popupMenu(getUserMenu(user), ev.currentTarget ?? ev.target); +} + onMounted(() => { if (typeof props.q === 'object') { user = props.q; @@ -174,6 +180,13 @@ onMounted(() => { } } + > .menu { + position: absolute; + top: 8px; + right: 42px; + padding: 8px; + } + > .koudoku-button { position: absolute; top: 8px; diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index ab66502e06..23a39b9ac9 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -1,37 +1,62 @@ <template> -<div v-if="show" ref="el" :class="[$style.root, { [$style.slim]: narrow, [$style.thin]: thin_ }]" :style="{ background: bg }" @click="onClick"> - <div v-if="narrow" :class="$style.buttonsLeft"> - <MkAvatar v-if="props.displayMyAvatar && $i" :class="$style.avatar" :user="$i"/> - </div> - <template v-if="metadata"> - <div v-if="!hideTitle" :class="$style.titleContainer" @click="showTabsPopup"> - <MkAvatar v-if="metadata.avatar" :class="$style.titleAvatar" :user="metadata.avatar" indicator/> - <i v-else-if="metadata.icon" :class="[$style.titleIcon, metadata.icon]"></i> +<div v-if="show" ref="el" :class="[$style.root]" :style="{ background: bg }"> + <div :class="[$style.upper, { [$style.slim]: narrow, [$style.thin]: thin_ }]"> + <div v-if="narrow && props.displayMyAvatar && $i" class="_button" :class="$style.buttonsLeft" @click="openAccountMenu"> + <MkAvatar :class="$style.avatar" :user="$i" /> + </div> + <div v-else-if="narrow && !hideTitle" :class="$style.buttonsLeft" /> + + <template v-if="metadata"> + <div v-if="!hideTitle" :class="$style.titleContainer" @click="top"> + <MkAvatar v-if="metadata.avatar" :class="$style.titleAvatar" :user="metadata.avatar" indicator/> + <i v-else-if="metadata.icon" :class="[$style.titleIcon, metadata.icon]"></i> - <div :class="$style.title"> - <MkUserName v-if="metadata.userName" :user="metadata.userName" :nowrap="true"/> - <div v-else-if="metadata.title">{{ metadata.title }}</div> - <div v-if="!narrow && metadata.subtitle" :class="$style.subtitle"> - {{ metadata.subtitle }} + <div :class="$style.title"> + <MkUserName v-if="metadata.userName" :user="metadata.userName" :nowrap="true"/> + <div v-else-if="metadata.title">{{ metadata.title }}</div> + <div v-if="metadata.subtitle" :class="$style.subtitle"> + {{ metadata.subtitle }} + </div> </div> - <div v-if="narrow && hasTabs" :class="[$style.subtitle, $style.activeTab]"> - {{ tabs.find(tab => tab.key === props.tab)?.title }} - <i class="ti ti-chevron-down" :class="$style.chevron"></i> + </div> + <div v-if="!narrow || hideTitle" :class="$style.tabs" @wheel="onTabWheel"> + <div :class="$style.tabsInner"> + <button v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title" class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab }]" @mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)"> + <div :class="$style.tabInner"> + <i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i> + <div v-if="!t.iconOnly" :class="$style.tabTitle">{{ t.title }}</div> + <Transition + v-else + @enter="enter" + @after-enter="afterEnter" + @leave="leave" + @after-leave="afterLeave" + mode="in-out" + > + <div v-if="t.key === tab" :class="$style.tabTitle">{{ t.title }}</div> + </Transition> + </div> + </button> </div> + <div ref="tabHighlightEl" :class="$style.tabHighlight"></div> </div> + </template> + <div v-if="(narrow && !hideTitle) || (actions && actions.length > 0)" :class="$style.buttonsRight"> + <template v-for="action in actions"> + <button v-tooltip.noDelay="action.text" class="_button" :class="[$style.button, { [$style.highlighted]: action.highlighted }]" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> + </template> </div> - <div v-if="!narrow || hideTitle" :class="$style.tabs"> - <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = (el as HTMLElement)" v-tooltip.noDelay="tab.title" class="_button" :class="[$style.tab, { [$style.active]: tab.key != null && tab.key === props.tab }]" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)"> - <i v-if="tab.icon" :class="[$style.tabIcon, tab.icon]"></i> - <span v-if="!tab.iconOnly" :class="$style.tabTitle">{{ tab.title }}</span> - </button> + </div> + <div v-if="(narrow && !hideTitle) && hasTabs" :class="[$style.lower, { [$style.slim]: narrow, [$style.thin]: thin_ }]"> + <div :class="$style.tabs" @wheel="onTabWheel"> + <div :class="$style.tabsInner"> + <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = (el as HTMLElement)" v-tooltip.noDelay="tab.title" class="_button" :class="[$style.tab, { [$style.active]: tab.key != null && tab.key === props.tab }]" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)"> + <i v-if="tab.icon" :class="[$style.tabIcon, tab.icon]"></i> + <span v-if="!tab.iconOnly" :class="$style.tabTitle">{{ tab.title }}</span> + </button> + </div> <div ref="tabHighlightEl" :class="$style.tabHighlight"></div> </div> - </template> - <div :class="$style.buttonsRight"> - <template v-for="action in actions"> - <button v-tooltip.noDelay="action.text" class="_button" :class="[$style.button, { [$style.highlighted]: action.highlighted }]" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> - </template> </div> </div> </template> @@ -39,11 +64,10 @@ <script lang="ts" setup> import { onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue'; import tinycolor from 'tinycolor2'; -import { popupMenu } from '@/os'; import { scrollToTop } from '@/scripts/scroll'; import { globalEvents } from '@/events'; import { injectPageMetadata } from '@/scripts/page-metadata'; -import { $i } from '@/account'; +import { $i, openAccountMenu as openAccountMenu_ } from '@/account'; type Tab = { key: string; @@ -77,9 +101,9 @@ const metadata = injectPageMetadata(); const hideTitle = inject('shouldOmitHeaderTitle', false); const thin_ = props.thin || inject('shouldHeaderThin', false); -const el = $shallowRef<HTMLElement | undefined>(undefined); +let el = $shallowRef<HTMLElement | undefined>(undefined); const tabRefs: Record<string, HTMLElement | null> = {}; -const tabHighlightEl = $shallowRef<HTMLElement | null>(null); +let tabHighlightEl = $shallowRef<HTMLElement | null>(null); const bg = ref<string | undefined>(undefined); let narrow = $ref(false); const hasTabs = $computed(() => props.tabs.length > 0); @@ -88,32 +112,22 @@ const show = $computed(() => { return !hideTitle || hasTabs || hasActions; }); -const showTabsPopup = (ev: MouseEvent) => { - if (!hasTabs) return; - if (!narrow) return; - ev.preventDefault(); - ev.stopPropagation(); - const menu = props.tabs.map(tab => ({ - text: tab.title, - icon: tab.icon, - active: tab.key != null && tab.key === props.tab, - action: (ev) => { - onTabClick(tab, ev); - }, - })); - popupMenu(menu, (ev.currentTarget ?? ev.target) as HTMLElement); -}; - const preventDrag = (ev: TouchEvent) => { ev.stopPropagation(); }; -const onClick = () => { +const top = () => { if (el) { scrollToTop(el as HTMLElement, { behavior: 'smooth' }); } }; +function openAccountMenu(ev: MouseEvent) { + openAccountMenu_({ + withExtraOperation: true, + }, ev); +} + function onTabMousedown(tab: Tab, ev: MouseEvent): void { // ユーザビリティの観点からmousedown時にはonClickは呼ばない if (tab.key) { @@ -121,14 +135,17 @@ function onTabMousedown(tab: Tab, ev: MouseEvent): void { } } -function onTabClick(tab: Tab, ev: MouseEvent): void { - if (tab.onClick) { +function onTabClick(t: Tab, ev: MouseEvent): void { + if (t.key === props.tab) { + top(); + } else if (t.onClick) { ev.preventDefault(); ev.stopPropagation(); - tab.onClick(ev); + t.onClick(ev); } - if (tab.key) { - emit('update:tab', tab.key); + + if (t.key) { + emit('update:tab', t.key); } } @@ -139,56 +156,124 @@ const calcBg = () => { bg.value = tinyBg.toRgbString(); }; -let ro: ResizeObserver | null; +let ro1: ResizeObserver | null; +let ro2: ResizeObserver | null; + +function renderTab() { + const tabEl = props.tab ? tabRefs[props.tab] : undefined; + if (tabEl && tabHighlightEl && tabHighlightEl.parentElement) { + // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある + // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 + const parentRect = tabHighlightEl.parentElement.getBoundingClientRect(); + const rect = tabEl.getBoundingClientRect(); + tabHighlightEl.style.width = rect.width + 'px'; + tabHighlightEl.style.left = (rect.left - parentRect.left + tabHighlightEl.parentElement.scrollLeft) + 'px'; + } +} + +function onTabWheel(ev: WheelEvent) { + if (ev.deltaY !== 0 && ev.deltaX === 0) { + ev.preventDefault(); + ev.stopPropagation(); + (ev.currentTarget as HTMLElement).scrollBy({ + left: ev.deltaY, + behavior: 'smooth', + }); + } + return false; +} + +function enter(el: HTMLElement) { + const elementWidth = el.getBoundingClientRect().width; + el.style.width = '0'; + el.offsetWidth; // reflow + el.style.width = elementWidth + 'px'; + setTimeout(renderTab, 70); +} +function afterEnter(el: HTMLElement) { + el.style.width = ''; + nextTick(renderTab); +} +function leave(el: HTMLElement) { + const elementWidth = el.getBoundingClientRect().width; + el.style.width = elementWidth + 'px'; + el.offsetWidth; // reflow + el.style.width = '0'; +} +function afterLeave(el: HTMLElement) { + el.style.width = ''; +} onMounted(() => { calcBg(); globalEvents.on('themeChanged', calcBg); - watch(() => [props.tab, props.tabs], () => { - nextTick(() => { - const tabEl = props.tab ? tabRefs[props.tab] : undefined; - if (tabEl && tabHighlightEl && tabEl.parentElement) { - // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある - // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 - const parentRect = tabEl.parentElement.getBoundingClientRect(); - const rect = tabEl.getBoundingClientRect(); - tabHighlightEl.style.width = rect.width + 'px'; - tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px'; - } - }); + watch([() => props.tab, () => props.tabs], () => { + nextTick(() => renderTab()); }, { immediate: true, }); if (el && el.parentElement) { narrow = el.parentElement.offsetWidth < 500; - ro = new ResizeObserver((entries, observer) => { - if (el.parentElement && document.body.contains(el as HTMLElement)) { + ro1 = new ResizeObserver((entries, observer) => { + if (el && el.parentElement && document.body.contains(el as HTMLElement)) { narrow = el.parentElement.offsetWidth < 500; } }); - ro.observe(el.parentElement as HTMLElement); + ro1.observe(el.parentElement as HTMLElement); + } + + if (el) { + ro2 = new ResizeObserver((entries, observer) => { + if (document.body.contains(el as HTMLElement)) { + nextTick(() => renderTab()); + } + }); + ro2.observe(el); } }); onUnmounted(() => { globalEvents.off('themeChanged', calcBg); - if (ro) ro.disconnect(); + if (ro1) ro1.disconnect(); + if (ro2) ro2.disconnect(); }); </script> <style lang="scss" module> .root { - --height: 50px; - display: flex; - width: 100%; -webkit-backdrop-filter: var(--blur, blur(15px)); backdrop-filter: var(--blur, blur(15px)); border-bottom: solid 0.5px var(--divider); - contain: strict; + width: 100%; +} + +.upper, +.lower { + width: 100%; + background: transparent; +} + +.upper { + --height: 50px; + display: flex; height: var(--height); + .tabs:first-child { + margin-left: auto; + } + .tabs:not(:first-child) { + padding-left: 16px; + mask-image: linear-gradient(90deg, rgba(0,0,0,0), rgb(0,0,0) 16px, rgb(0,0,0) 100%); + } + .tabs:last-child { + margin-right: auto; + } + .tabs:not(:last-child) { + margin-right: 0; + } + &.thin { --height: 42px; @@ -205,6 +290,7 @@ onUnmounted(() => { > .titleContainer { flex: 1; margin: 0 auto; + max-width: 100%; > *:first-child { margin-left: auto; @@ -217,6 +303,11 @@ onUnmounted(() => { } } +.lower { + --height: 40px; + height: var(--height); +} + .buttons { --margin: 8px; display: flex; @@ -247,15 +338,14 @@ onUnmounted(() => { height: $size; vertical-align: bottom; margin: 0 8px; - pointer-events: none; } .button { display: flex; align-items: center; justify-content: center; - height: calc(var(--height) - (var(--margin) * 2)); - width: calc(var(--height) - (var(--margin) * 2)); + height: var(--height); + width: calc(var(--height) - (var(--margin))); box-sizing: border-box; position: relative; border-radius: 5px; @@ -278,7 +368,7 @@ onUnmounted(() => { .titleContainer { display: flex; align-items: center; - max-width: 400px; + max-width: min(30vw, 400px); overflow: auto; white-space: nowrap; text-align: left; @@ -330,10 +420,24 @@ onUnmounted(() => { } .tabs { + display: block; position: relative; - margin-left: 16px; + margin: 0; + height: var(--height); font-size: 0.8em; - overflow: auto; + text-align: center; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.tabsInner { + display: inline-block; + height: var(--height); white-space: nowrap; } @@ -344,6 +448,7 @@ onUnmounted(() => { height: 100%; font-weight: normal; opacity: 0.7; + transition: opacity 0.2s ease; &:hover { opacity: 1; @@ -354,8 +459,18 @@ onUnmounted(() => { } } +.tabInner { + display: flex; + align-items: center; +} + .tabIcon + .tabTitle { margin-left: 8px; +} + +.tabTitle { + overflow: hidden; + transition: width 0.15s ease-in-out; } .tabHighlight { @@ -364,7 +479,7 @@ onUnmounted(() => { height: 3px; background: var(--accent); border-radius: 999px; - transition: all 0.2s ease; + transition: width 0.15s ease, left 0.15s ease; pointer-events: none; } </style> diff --git a/packages/frontend/src/components/mfm.ts b/packages/frontend/src/components/mfm.ts index 683c9014a1..816a42a5fb 100644 --- a/packages/frontend/src/components/mfm.ts +++ b/packages/frontend/src/components/mfm.ts @@ -12,6 +12,7 @@ import MkSparkle from '@/components/MkSparkle.vue'; import MkA from '@/components/global/MkA.vue'; import { host } from '@/config'; import { MFM_TAGS } from '@/scripts/mfm-tags'; +import { defaultStore } from '@/store'; const QUOTE_STYLE = ` display: block; @@ -64,6 +65,8 @@ export default defineComponent({ return t.match(/^[0-9.]+s$/) ? t : null; }; + const useAnim = defaultStore.state.advancedMfm && defaultStore.state.animatedMfm; + const genEl = (ast: mfm.MfmNode[]) => ast.map((token): VNode | string | (VNode | string)[] => { switch (token.type) { case 'text': { @@ -102,22 +105,22 @@ export default defineComponent({ switch (token.props.name) { case 'tada': { const speed = validTime(token.props.args.speed) ?? '1s'; - style = 'font-size: 150%;' + (this.$store.state.animatedMfm ? `animation: tada ${speed} linear infinite both;` : ''); + style = 'font-size: 150%;' + (useAnim ? `animation: tada ${speed} linear infinite both;` : ''); break; } case 'jelly': { const speed = validTime(token.props.args.speed) ?? '1s'; - style = (this.$store.state.animatedMfm ? `animation: mfm-rubberBand ${speed} linear infinite both;` : ''); + style = (useAnim ? `animation: mfm-rubberBand ${speed} linear infinite both;` : ''); break; } case 'twitch': { const speed = validTime(token.props.args.speed) ?? '0.5s'; - style = this.$store.state.animatedMfm ? `animation: mfm-twitch ${speed} ease infinite;` : ''; + style = useAnim ? `animation: mfm-twitch ${speed} ease infinite;` : ''; break; } case 'shake': { const speed = validTime(token.props.args.speed) ?? '0.5s'; - style = this.$store.state.animatedMfm ? `animation: mfm-shake ${speed} ease infinite;` : ''; + style = useAnim ? `animation: mfm-shake ${speed} ease infinite;` : ''; break; } case 'spin': { @@ -130,17 +133,17 @@ export default defineComponent({ token.props.args.y ? 'mfm-spinY' : 'mfm-spin'; const speed = validTime(token.props.args.speed) ?? '1.5s'; - style = this.$store.state.animatedMfm ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : ''; + style = useAnim ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : ''; break; } case 'jump': { const speed = validTime(token.props.args.speed) ?? '0.75s'; - style = this.$store.state.animatedMfm ? `animation: mfm-jump ${speed} linear infinite;` : ''; + style = useAnim ? `animation: mfm-jump ${speed} linear infinite;` : ''; break; } case 'bounce': { const speed = validTime(token.props.args.speed) ?? '0.75s'; - style = this.$store.state.animatedMfm ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : ''; + style = useAnim ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : ''; break; } case 'flip': { @@ -153,17 +156,17 @@ export default defineComponent({ } case 'x2': { return h('span', { - class: 'mfm-x2', + class: defaultStore.state.advancedMfm ? 'mfm-x2' : '', }, genEl(token.children)); } case 'x3': { return h('span', { - class: 'mfm-x3', + class: defaultStore.state.advancedMfm ? 'mfm-x3' : '', }, genEl(token.children)); } case 'x4': { return h('span', { - class: 'mfm-x4', + class: defaultStore.state.advancedMfm ? 'mfm-x4' : '', }, genEl(token.children)); } case 'font': { @@ -185,11 +188,11 @@ export default defineComponent({ } case 'rainbow': { const speed = validTime(token.props.args.speed) ?? '1s'; - style = this.$store.state.animatedMfm ? `animation: mfm-rainbow ${speed} linear infinite;` : ''; + style = useAnim ? `animation: mfm-rainbow ${speed} linear infinite;` : ''; break; } case 'sparkle': { - if (!this.$store.state.animatedMfm) { + if (!useAnim) { return genEl(token.children); } return h(MkSparkle, {}, genEl(token.children)); @@ -200,12 +203,17 @@ export default defineComponent({ break; } case 'position': { + if (!defaultStore.state.advancedMfm) break; const x = parseFloat(token.props.args.x ?? '0'); const y = parseFloat(token.props.args.y ?? '0'); style = `transform: translateX(${x}em) translateY(${y}em);`; break; } case 'scale': { + if (!defaultStore.state.advancedMfm) { + style = ''; + break; + } const x = Math.min(parseFloat(token.props.args.x ?? '1'), 5); const y = Math.min(parseFloat(token.props.args.y ?? '1'), 5); style = `transform: scale(${x}, ${y});`; diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index e21a21ef76..639f4eaf17 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -186,6 +186,38 @@ export function confirm(props: { }); } +// TODO: const T extends ... にしたい +// https://zenn.dev/general_link/articles/813e47b7a0eef7#const-type-parameters +export function actions<T extends { + value: string; + text: string; + primary?: boolean, +}[]>(props: { + type: 'error' | 'info' | 'success' | 'warning' | 'waiting' | 'question'; + title?: string | null; + text?: string | null; + actions: T; +}): Promise<{ canceled: true; result: undefined; } | { + canceled: false; result: T[number]['value']; +}> { + return new Promise((resolve, reject) => { + popup(MkDialog, { + ...props, + actions: props.actions.map(a => ({ + text: a.text, + primary: a.primary, + callback: () => { + resolve({ canceled: false, result: a.value }); + }, + })), + }, { + done: result => { + resolve(result ? result : { canceled: true }); + }, + }, 'closed'); + }); +} + export function inputText(props: { type?: 'text' | 'email' | 'password' | 'url'; title?: string | null; @@ -540,3 +572,9 @@ export function checkExistence(fileData: ArrayBuffer): Promise<any> { }); }); }*/ + +export const shownNoteIds = new Set(); + +window.setInterval(() => { + shownNoteIds.clear(); +}, 1000 * 60 * 5); diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index f8e9780714..bc3d248193 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -192,6 +192,7 @@ const patrons = [ '蝉暮せせせ', 'ThatOneCalculator', 'pixeldesu', + 'あめ玉', ]; let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure')); diff --git a/packages/frontend/src/pages/antenna-timeline.vue b/packages/frontend/src/pages/antenna-timeline.vue index bf3004f280..ff31c3ab2c 100644 --- a/packages/frontend/src/pages/antenna-timeline.vue +++ b/packages/frontend/src/pages/antenna-timeline.vue @@ -98,6 +98,7 @@ definePageMetadata(computed(() => antenna ? { top: calc(var(--stickyTop, 0px) + 16px); z-index: 1000; width: 100%; + margin: calc(-0.675em - 8px - var(--margin)) 0 calc(-0.675em - 8px); > button { display: block; diff --git a/packages/frontend/src/pages/mfm-cheat-sheet.vue b/packages/frontend/src/pages/mfm-cheat-sheet.vue deleted file mode 100644 index 73a5716236..0000000000 --- a/packages/frontend/src/pages/mfm-cheat-sheet.vue +++ /dev/null @@ -1,377 +0,0 @@ -<template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> - <MkSpacer :content-max="800"> - <div class="mwysmxbg"> - <div>{{ i18n.ts._mfm.intro }}</div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.mention }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.mentionDescription }}</p> - <div class="preview"> - <Mfm :text="preview_mention"/> - <MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.hashtag }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.hashtagDescription }}</p> - <div class="preview"> - <Mfm :text="preview_hashtag"/> - <MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.url }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.urlDescription }}</p> - <div class="preview"> - <Mfm :text="preview_url"/> - <MkTextarea v-model="preview_url"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.link }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.linkDescription }}</p> - <div class="preview"> - <Mfm :text="preview_link"/> - <MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.emoji }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.emojiDescription }}</p> - <div class="preview"> - <Mfm :text="preview_emoji"/> - <MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.bold }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.boldDescription }}</p> - <div class="preview"> - <Mfm :text="preview_bold"/> - <MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.small }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.smallDescription }}</p> - <div class="preview"> - <Mfm :text="preview_small"/> - <MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.quote }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.quoteDescription }}</p> - <div class="preview"> - <Mfm :text="preview_quote"/> - <MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.center }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.centerDescription }}</p> - <div class="preview"> - <Mfm :text="preview_center"/> - <MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.inlineCode }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.inlineCodeDescription }}</p> - <div class="preview"> - <Mfm :text="preview_inlineCode"/> - <MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.blockCode }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.blockCodeDescription }}</p> - <div class="preview"> - <Mfm :text="preview_blockCode"/> - <MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <!-- deprecated - <div class="section"> - <div class="title">{{ i18n.ts._mfm.search }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.searchDescription }}</p> - <div class="preview"> - <Mfm :text="preview_search"/> - <MkTextarea v-model="preview_search"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - --> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.flip }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.flipDescription }}</p> - <div class="preview"> - <Mfm :text="preview_flip"/> - <MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.font }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.fontDescription }}</p> - <div class="preview"> - <Mfm :text="preview_font"/> - <MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.x2 }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.x2Description }}</p> - <div class="preview"> - <Mfm :text="preview_x2"/> - <MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.x3 }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.x3Description }}</p> - <div class="preview"> - <Mfm :text="preview_x3"/> - <MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.x4 }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.x4Description }}</p> - <div class="preview"> - <Mfm :text="preview_x4"/> - <MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.blur }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.blurDescription }}</p> - <div class="preview"> - <Mfm :text="preview_blur"/> - <MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.jelly }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.jellyDescription }}</p> - <div class="preview"> - <Mfm :text="preview_jelly"/> - <MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.tada }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.tadaDescription }}</p> - <div class="preview"> - <Mfm :text="preview_tada"/> - <MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.jump }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.jumpDescription }}</p> - <div class="preview"> - <Mfm :text="preview_jump"/> - <MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.bounce }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.bounceDescription }}</p> - <div class="preview"> - <Mfm :text="preview_bounce"/> - <MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.spin }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.spinDescription }}</p> - <div class="preview"> - <Mfm :text="preview_spin"/> - <MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.shake }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.shakeDescription }}</p> - <div class="preview"> - <Mfm :text="preview_shake"/> - <MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.twitch }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.twitchDescription }}</p> - <div class="preview"> - <Mfm :text="preview_twitch"/> - <MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.rainbow }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.rainbowDescription }}</p> - <div class="preview"> - <Mfm :text="preview_rainbow"/> - <MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.sparkle }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.sparkleDescription }}</p> - <div class="preview"> - <Mfm :text="preview_sparkle"/> - <MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.rotate }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.rotateDescription }}</p> - <div class="preview"> - <Mfm :text="preview_rotate"/> - <MkTextarea v-model="preview_rotate"><span>MFM</span></MkTextarea> - </div> - </div> - </div> - <div class="section"> - <div class="title">{{ i18n.ts._mfm.plain }}</div> - <div class="content"> - <p>{{ i18n.ts._mfm.plainDescription }}</p> - <div class="preview"> - <Mfm :text="preview_plain"/> - <MkTextarea v-model="preview_plain"><span>MFM</span></MkTextarea> - </div> - </div> - </div> - </div> - </MkSpacer> -</MkStickyContainer> -</template> - -<script lang="ts" setup> -import { defineComponent } from 'vue'; -import MkTextarea from '@/components/MkTextarea.vue'; -import { definePageMetadata } from '@/scripts/page-metadata'; -import { i18n } from '@/i18n'; -import { instance } from '@/instance'; -import { customEmojis } from '@/custom-emojis'; - -let preview_mention = $ref('@example'); -let preview_hashtag = $ref('#test'); -let preview_url = $ref('https://example.com'); -let preview_link = $ref(`[${i18n.ts._mfm.dummy}](https://example.com)`); -let preview_emoji = $ref(customEmojis.value.length ? `:${customEmojis.value[0].name}:` : ':emojiname:'); -let preview_bold = $ref(`**${i18n.ts._mfm.dummy}**`); -let preview_small = $ref(`<small>${i18n.ts._mfm.dummy}</small>`); -let preview_center = $ref(`<center>${i18n.ts._mfm.dummy}</center>`); -let preview_inlineCode = $ref('`<: "Hello, world!"`'); -let preview_blockCode = $ref('```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```'); -let preview_quote = $ref(`> ${i18n.ts._mfm.dummy}`); -let preview_search = $ref(`${i18n.ts._mfm.dummy} 検索`); -let preview_jelly = $ref('$[jelly 🍮] $[jelly.speed=5s 🍮]'); -let preview_tada = $ref('$[tada 🍮] $[tada.speed=5s 🍮]'); -let preview_jump = $ref('$[jump 🍮] $[jump.speed=5s 🍮]'); -let preview_bounce = $ref('$[bounce 🍮] $[bounce.speed=5s 🍮]'); -let preview_shake = $ref('$[shake 🍮] $[shake.speed=5s 🍮]'); -let preview_twitch = $ref('$[twitch 🍮] $[twitch.speed=5s 🍮]'); -let preview_spin = $ref('$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=5s 🍮]'); -let preview_flip = $ref(`$[flip ${i18n.ts._mfm.dummy}]\n$[flip.v ${i18n.ts._mfm.dummy}]\n$[flip.h,v ${i18n.ts._mfm.dummy}]`); -let preview_font = $ref(`$[font.serif ${i18n.ts._mfm.dummy}]\n$[font.monospace ${i18n.ts._mfm.dummy}]\n$[font.cursive ${i18n.ts._mfm.dummy}]\n$[font.fantasy ${i18n.ts._mfm.dummy}]`); -let preview_x2 = $ref('$[x2 🍮]'); -let preview_x3 = $ref('$[x3 🍮]'); -let preview_x4 = $ref('$[x4 🍮]'); -let preview_blur = $ref(`$[blur ${i18n.ts._mfm.dummy}]`); -let preview_rainbow = $ref('$[rainbow 🍮] $[rainbow.speed=5s 🍮]'); -let preview_sparkle = $ref('$[sparkle 🍮]'); -let preview_rotate = $ref('$[rotate 🍮]'); -let preview_plain = $ref('<plain>**bold** @mention #hashtag `code` $[x2 🍮]</plain>'); - -definePageMetadata({ - title: i18n.ts._mfm.cheatSheet, - icon: 'ti ti-question-circle', -}); -</script> - -<style lang="scss" scoped> -.mwysmxbg { - background: var(--bg); - - > .section { - > .title { - position: sticky; - z-index: 1; - top: var(--stickyTop, 0px); - padding: 16px; - font-weight: bold; - -webkit-backdrop-filter: var(--blur, blur(10px)); - backdrop-filter: var(--blur, blur(10px)); - background-color: var(--X16); - } - - > .content { - > p { - margin: 0; - padding: 16px; - } - - > .preview { - border-top: solid 0.5px var(--divider); - padding: 16px; - } - } - } -} -</style> diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index 0ce66b065f..b4851df176 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -45,7 +45,8 @@ <div class="_gaps_m"> <div class="_gaps_s"> - <MkSwitch v-model="disableAnimatedMfm">{{ i18n.ts.disableAnimatedMfm }}</MkSwitch> + <MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch> + <MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch> <MkSwitch v-model="reduceAnimation">{{ i18n.ts.reduceUiAnimation }}</MkSwitch> <MkSwitch v-model="useBlurEffect">{{ i18n.ts.useBlurEffect }}</MkSwitch> <MkSwitch v-model="useBlurEffectForModal">{{ i18n.ts.useBlurEffectForModal }}</MkSwitch> @@ -142,7 +143,8 @@ const reduceAnimation = computed(defaultStore.makeGetterSetter('animation', v => const useBlurEffectForModal = computed(defaultStore.makeGetterSetter('useBlurEffectForModal')); const useBlurEffect = computed(defaultStore.makeGetterSetter('useBlurEffect')); const showGapBetweenNotesInTimeline = computed(defaultStore.makeGetterSetter('showGapBetweenNotesInTimeline')); -const disableAnimatedMfm = computed(defaultStore.makeGetterSetter('animatedMfm', v => !v, v => !v)); +const animatedMfm = computed(defaultStore.makeGetterSetter('animatedMfm')); +const advancedMfm = computed(defaultStore.makeGetterSetter('advancedMfm')); const emojiStyle = computed(defaultStore.makeGetterSetter('emojiStyle')); const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer')); const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages')); diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue index 87a08612fc..0512a8d0c9 100644 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -62,6 +62,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ 'nsfw', 'animation', 'animatedMfm', + 'advancedMfm', 'loadRawImages', 'imageNewTab', 'disableShowingAnimatedImages', diff --git a/packages/frontend/src/pages/share.vue b/packages/frontend/src/pages/share.vue index d8b956b6d1..78e0710162 100644 --- a/packages/frontend/src/pages/share.vue +++ b/packages/frontend/src/pages/share.vue @@ -2,7 +2,7 @@ <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="800"> - <XPostForm + <MkPostForm v-if="state === 'writing'" fixed :instant="true" @@ -37,17 +37,17 @@ import { i18n } from '@/i18n'; const urlParams = new URLSearchParams(window.location.search); const localOnlyQuery = urlParams.get('localOnly'); -const visibilityQuery = urlParams.get('visibility'); +const visibilityQuery = urlParams.get('visibility') as typeof noteVisibilities[number]; let state = $ref('fetching' as 'fetching' | 'writing' | 'posted'); let title = $ref(urlParams.get('title')); const text = urlParams.get('text'); const url = urlParams.get('url'); -let initialText = $ref(null as string | null); -let reply = $ref(null as Misskey.entities.Note | null); -let renote = $ref(null as Misskey.entities.Note | null); -let visibility = $ref(noteVisibilities.includes(visibilityQuery) ? visibilityQuery : null); -let localOnly = $ref(localOnlyQuery === '0' ? false : localOnlyQuery === '1' ? true : null); +let initialText = $ref<string | undefined>(); +let reply = $ref<Misskey.entities.Note | undefined>(); +let renote = $ref<Misskey.entities.Note | undefined>(); +let visibility = $ref(noteVisibilities.includes(visibilityQuery) ? visibilityQuery : undefined); +let localOnly = $ref(localOnlyQuery === '0' ? false : localOnlyQuery === '1' ? true : undefined); let files = $ref([] as Misskey.entities.DriveFile[]); let visibleUsers = $ref([] as Misskey.entities.User[]); @@ -130,7 +130,7 @@ async function init() { ); } //#endregion - } catch (err) { + } catch (err: any) { os.alert({ type: 'error', title: err.message, diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 057409484c..31f4793dc4 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -22,7 +22,7 @@ </template> <script lang="ts" setup> -import { defineAsyncComponent, computed, watch } from 'vue'; +import { defineAsyncComponent, computed, watch, provide } from 'vue'; import XTimeline from '@/components/MkTimeline.vue'; import MkPostForm from '@/components/MkPostForm.vue'; import { scroll } from '@/scripts/scroll'; @@ -33,6 +33,8 @@ import { instance } from '@/instance'; import { $i } from '@/account'; import { definePageMetadata } from '@/scripts/page-metadata'; +provide('shouldOmitHeaderTitle', true); + const XTutorial = defineAsyncComponent(() => import('./timeline.tutorial.vue')); const isLocalTimelineAvailable = ($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable); @@ -177,6 +179,11 @@ definePageMetadata(computed(() => ({ top: calc(var(--stickyTop, 0px) + 16px); z-index: 1000; width: 100%; + margin: calc(-0.675em - 8px) 0; + + &:first-child { + margin-top: calc(-0.675em - 8px - var(--margin)); + } > button { display: block; diff --git a/packages/frontend/src/pages/user-list-timeline.vue b/packages/frontend/src/pages/user-list-timeline.vue index 6817d44d8c..3f47edfd44 100644 --- a/packages/frontend/src/pages/user-list-timeline.vue +++ b/packages/frontend/src/pages/user-list-timeline.vue @@ -91,6 +91,7 @@ definePageMetadata(computed(() => list ? { top: calc(var(--stickyTop, 0px) + 16px); z-index: 1000; width: 100%; + margin: calc(-0.675em - 8px - var(--margin)) 0 calc(-0.675em - 8px); > button { display: block; diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 56858a9377..fab47d09e2 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -101,9 +101,6 @@ <XActivity :key="user.id" :user="user"/> </template> </div> - <div> - <XUserTimeline :user="user"/> - </div> </div> <div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;"> <XPhotos :key="user.id" :user="user"/> @@ -117,7 +114,6 @@ import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; import calcAge from 's-age'; import * as misskey from 'misskey-js'; -import XUserTimeline from './index.timeline.vue'; import XNote from '@/components/MkNote.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; import MkContainer from '@/components/MkContainer.vue'; diff --git a/packages/frontend/src/pages/user/index.timeline.vue b/packages/frontend/src/pages/user/index.timeline.vue index 41983a5ae8..34e16c707d 100644 --- a/packages/frontend/src/pages/user/index.timeline.vue +++ b/packages/frontend/src/pages/user/index.timeline.vue @@ -1,14 +1,16 @@ <template> -<MkStickyContainer> - <template #header> - <MkTab v-model="include" :class="$style.tab"> - <option :value="null">{{ i18n.ts.notes }}</option> - <option value="replies">{{ i18n.ts.notesAndReplies }}</option> - <option value="files">{{ i18n.ts.withFiles }}</option> - </MkTab> - </template> - <XNotes :no-gap="true" :pagination="pagination"/> -</MkStickyContainer> +<MkSpacer :content-max="800" style="padding-top: 0"> + <MkStickyContainer> + <template #header> + <MkTab v-model="include" :class="$style.tab"> + <option :value="null">{{ i18n.ts.notes }}</option> + <option value="replies">{{ i18n.ts.notesAndReplies }}</option> + <option value="files">{{ i18n.ts.withFiles }}</option> + </MkTab> + </template> + <XNotes :no-gap="true" :pagination="pagination" :class="$style.tl"/> + </MkStickyContainer> +</MkSpacer> </template> <script lang="ts" setup> @@ -42,4 +44,10 @@ const pagination = { padding: calc(var(--margin) / 2) 0; background: var(--bg); } + +.tl { + background: var(--bg); + border-radius: var(--radius); + overflow: clip; +} </style> diff --git a/packages/frontend/src/pages/user/index.vue b/packages/frontend/src/pages/user/index.vue index 120f67d8a5..84909f72c2 100644 --- a/packages/frontend/src/pages/user/index.vue +++ b/packages/frontend/src/pages/user/index.vue @@ -5,7 +5,7 @@ <Transition name="fade" mode="out-in"> <div v-if="user"> <XHome v-if="tab === 'home'" :user="user"/> - <XActivity v-else-if="tab === 'activity'" :user="user"/> + <XTimeline v-else-if="tab === 'notes'" :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"/> @@ -34,6 +34,7 @@ import { i18n } from '@/i18n'; import { $i } from '@/account'; const XHome = defineAsyncComponent(() => import('./home.vue')); +const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue')); const XActivity = defineAsyncComponent(() => import('./activity.vue')); const XAchievements = defineAsyncComponent(() => import('./achievements.vue')); const XReactions = defineAsyncComponent(() => import('./reactions.vue')); @@ -75,6 +76,10 @@ const headerTabs = $computed(() => user ? [{ title: i18n.ts.overview, icon: 'ti ti-home', }, { + key: 'notes', + title: i18n.ts.notes, + icon: 'ti ti-pencil', +}, { key: 'activity', title: i18n.ts.activity, icon: 'ti ti-chart-line', diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index 87d42c5c87..9004262689 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -225,9 +225,6 @@ export const routes = [{ component: page(() => import('./pages/api-console.vue')), loginRequired: true, }, { - path: '/mfm-cheat-sheet', - component: page(() => import('./pages/mfm-cheat-sheet.vue')), -}, { path: '/scratchpad', component: page(() => import('./pages/scratchpad.vue')), }, { diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 74bd61fd78..941d9a0db9 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -203,6 +203,20 @@ export function getUserMenu(user, router: Router = mainRouter) { action: () => { router.push('/user-info/' + user.id + '#moderation'); }, + }, { + icon: 'ti ti-badges', + text: i18n.ts.roles, + action: async () => { + const roles = await os.api('admin/roles/list'); + + const { canceled, result: roleId } = await os.select({ + title: i18n.ts._role.chooseRoleToAssign, + items: roles.map(r => ({ text: r.name, value: r.id })), + }); + if (canceled) return; + + await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.id }); + }, }]); } } diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 94892ff526..80bd22a813 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -158,6 +158,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, + advancedMfm: { + where: 'device', + default: true, + }, loadRawImages: { where: 'device', default: false, |