Merge branch 'develop' of codeberg.org:calckey/calckey into develop

This commit is contained in:
ThatOneCalculator 2023-03-17 09:31:47 -07:00
commit 3066d6079a
No known key found for this signature in database
GPG Key ID: 8703CACD01000000
22 changed files with 543 additions and 182 deletions

View File

@ -835,7 +835,7 @@ muteThread: "Mute thread"
unmuteThread: "Unmute thread"
ffVisibility: "Follows/Followers Visibility"
ffVisibilityDescription: "Allows you to configure who can see who you follow and who follows you."
continueThread: "View thread continuation"
continueThread: "Continue thread"
deleteAccountConfirm: "This will irreversibly delete your account. Proceed?"
incorrectPassword: "Incorrect password."
voteConfirm: "Confirm your vote for \"{choice}\"?"

View File

@ -359,7 +359,7 @@ export function apiAccountMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = (await client.getBookmarks(ctx.query as any)) as any;
const data = (await client.getBookmarks(limitToInt(ctx.query as any))) as any;
let resp = data.data;
for (let statIdx = 0; statIdx < resp.length; statIdx++) {
resp[statIdx].id = convertId(resp[statIdx].id, IdType.MastodonId);
@ -383,7 +383,7 @@ export function apiAccountMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getFavourites(ctx.query as any);
const data = await client.getFavourites(limitToInt(ctx.query as any));
let resp = data.data;
for (let statIdx = 0; statIdx < resp.length; statIdx++) {
resp[statIdx].id = convertId(resp[statIdx].id, IdType.MastodonId);
@ -407,7 +407,7 @@ export function apiAccountMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getMutes(ctx.query as any);
const data = await client.getMutes(limitToInt(ctx.query as any));
let resp = data.data;
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId);
@ -425,7 +425,7 @@ export function apiAccountMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getBlocks(ctx.query as any);
const data = await client.getBlocks(limitToInt(ctx.query as any));
let resp = data.data;
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId);

View File

@ -4,6 +4,8 @@ import { emojiRegexAtStartToEnd } from "@/misc/emoji-regex.js";
import axios from "axios";
import querystring from 'node:querystring'
import qs from 'qs'
import { limitToInt } from "./timeline.js";
function normalizeQuery(data: any) {
const str = querystring.stringify(data);
return qs.parse(str);
@ -101,7 +103,7 @@ export function apiStatusMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
try {
const id = ctx.params.id;
const data = await client.getStatusContext(id, ctx.query as any);
const data = await client.getStatusContext(id, limitToInt(ctx.query as any));
const status = await client.getStatus(id);
const reactionsAxios = await axios.get(
`${BASE_URL}/api/notes/reactions?noteId=${id}`,

View File

@ -15,13 +15,16 @@ export function limitToInt(q: ParsedUrlQuery) {
}
export function argsToBools(q: ParsedUrlQuery) {
// Values taken from https://docs.joinmastodon.org/client/intro/#boolean
const toBoolean = (value: string) => !['0', 'f', 'F', 'false', 'FALSE', 'off', 'OFF'].includes(value);
let object: any = q;
if (q.only_media)
if (typeof q.only_media === "string")
object.only_media = q.only_media.toLowerCase() === "true";
object.only_media = toBoolean(q.only_media);
if (q.exclude_replies)
if (typeof q.exclude_replies === "string")
object.exclude_replies = q.exclude_replies.toLowerCase() === "true";
object.exclude_replies = toBoolean(q.exclude_replies);
return q;
}

View File

@ -1,5 +1,5 @@
<template>
<button class="nrvgflfu _button" @click.stop.prevent="toggle">
<button class="nrvgflfu _button" @click.stop="toggle">
<b>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}</b>
<span v-if="!modelValue">{{ label }}</span>
</button>
@ -36,6 +36,8 @@ const toggle = () => {
<style lang="scss" scoped>
.nrvgflfu {
position: relative;
z-index: 2;
display: inline-block;
padding: 4px 8px;
font-size: 0.8em;

View File

@ -2,7 +2,7 @@
<div class="hoawjimk">
<XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/>
<div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container" :class="{ dmWidth: inDm }">
<div ref="gallery" :data-count="mediaList.filter(media => previewable(media)).length" @click.stop.prevent>
<div ref="gallery" :data-count="mediaList.filter(media => previewable(media)).length" @click.stop>
<template v-for="media in mediaList.filter(media => previewable(media))">
<XVideo v-if="media.type.startsWith('video')" :key="media.id" :video="media"/>
<XImage v-else-if="media.type.startsWith('image')" :key="media.id" class="image" :data-id="media.id" :image="media" :raw="raw"/>

View File

@ -1,12 +1,12 @@
<template>
<MkA v-if="url.startsWith('/')" v-user-preview="canonical" class="akbvjaqn" :class="{ isMe }" :to="url" :style="{ background: bgCss }">
<MkA v-if="url.startsWith('/')" v-user-preview="canonical" class="akbvjaqn" :class="{ isMe }" :to="url" :style="{ background: bgCss }" @click.stop>
<img class="icon" :src="`/avatar/@${username}@${host}`" alt="">
<span class="main">
<span class="username">@{{ username }}</span>
<span v-if="(host != localHost) || $store.state.showFullAcct" class="host">@{{ toUnicode(host) }}</span>
</span>
</MkA>
<a v-else class="akbvjaqn" :href="url" target="_blank" rel="noopener" :style="{ background: bgCss }">
<a v-else class="akbvjaqn" :href="url" target="_blank" rel="noopener" :style="{ background: bgCss }" @click.stop>
<span class="main">
<span class="username">@{{ username }}</span>
<span class="host">@{{ toUnicode(host) }}</span>
@ -42,8 +42,13 @@ const bgCss = bg.toRgbString();
<style lang="scss" scoped>
.akbvjaqn {
display: inline-block;
padding: 4px 8px 4px 4px;
padding: 2px 8px 2px 2px;
margin-block: 2px;
border-radius: 999px;
max-width: 100%;
white-space: nowrap;
overflow: clip;
text-overflow: ellipsis;
color: var(--mention);
&.isMe {

View File

@ -25,7 +25,7 @@
</template>
</I18n>
<div class="info">
<button ref="renoteTime" class="_button time" @click="showRenoteMenu()">
<button ref="renoteTime" class="_button time" @click.stop="showRenoteMenu()">
<i v-if="isMyRenote" class="ph-dots-three-outline ph-bold ph-lg dropdownIcon"></i>
<MkTime :time="note.createdAt"/>
</button>
@ -33,24 +33,24 @@
</div>
</div>
</div>
<article class="article" @contextmenu.stop="onContextmenu" @click.self="router.push(notePage(appearNote))">
<div class="main" @click.self="router.push(notePage(appearNote))">
<article class="article" @contextmenu.stop="onContextmenu" @click="router.push(notePage(appearNote))">
<div class="main">
<div class="header-container">
<MkAvatar class="avatar" :user="appearNote.user"/>
<XNoteHeader class="header" :note="appearNote" :mini="true"/>
</div>
<div class="body">
<p v-if="appearNote.cw != null" class="cw">
<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis" @click.stop/>
<XCwButton v-model="showContent" :note="appearNote"/>
</p>
<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed, isLong }">
<div class="text" @click.self="router.push(notePage(appearNote))">
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
<div class="text">
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis" @click.stop/>
<!-- <a v-if="appearNote.renote != null" class="rp">RN:</a> -->
<div v-if="translating || translation" class="translation">
<MkLoading v-if="translating" mini/>
<div v-else class="translated">
<div v-else class="translated" @click.stop>
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
</div>
@ -61,36 +61,17 @@
</div>
<XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/>
<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div>
<button v-if="isLong && collapsed" class="fade _button" @click.stop.prevent="collapsed = false">
<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote" @click.stop="router.push(notePage(appearNote.renote))"/></div>
<button v-if="isLong && collapsed" class="fade _button" @click.stop="collapsed = false">
<span>{{ i18n.ts.showMore }}</span>
</button>
<button v-else-if="isLong && !collapsed" class="showLess _button" @click.stop.prevent="collapsed = true">
<button v-else-if="isLong && !collapsed" class="showLess _button" @click.stop="collapsed = true">
<span>{{ i18n.ts.showLess }}</span>
</button>
</div>
<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`" @click.stop><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
</div>
<footer class="footer">
<XReactionsViewer ref="reactionsViewer" :note="appearNote"/>
<button v-tooltip.noDelay.bottom="i18n.ts.reply" class="button _button" @click="reply()">
<template v-if="appearNote.reply"><i class="ph-arrow-u-up-left ph-bold ph-lg"></i></template>
<template v-else><i class="ph-arrow-bend-up-left ph-bold ph-lg"></i></template>
<p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p>
</button>
<XRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/>
<XStarButton v-if="appearNote.myReaction == null" ref="starButton" class="button" :note="appearNote"/>
<button v-if="appearNote.myReaction == null" ref="reactButton" v-tooltip.noDelay.bottom="i18n.ts.reaction" class="button _button" @click="react()">
<i class="ph-smiley ph-bold ph-lg"></i>
</button>
<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)">
<i class="ph-minus ph-bold ph-lg"></i>
</button>
<XQuoteButton class="button" :note="appearNote"/>
<button ref="menuButton" v-tooltip.noDelay.bottom="i18n.ts.more" class="button _button" @click="menu()">
<i class="ph-dots-three-outline ph-bold ph-lg"></i>
</button>
</footer>
<MkNoteFooter :note="appearNote"></MkNoteFooter>
</div>
</article>
</div>
@ -113,15 +94,12 @@ import type * as misskey from 'calckey-js';
import MkNoteSub from '@/components/MkNoteSub.vue';
import XNoteHeader from '@/components/MkNoteHeader.vue';
import XNoteSimple from '@/components/MkNoteSimple.vue';
import XReactionsViewer from '@/components/MkReactionsViewer.vue';
import XMediaList from '@/components/MkMediaList.vue';
import XCwButton from '@/components/MkCwButton.vue';
import MkNoteFooter from '@/components/MkNoteFooter.vue';
import XPoll from '@/components/MkPoll.vue';
import XStarButton from '@/components/MkStarButton.vue';
import XRenoteButton from '@/components/MkRenoteButton.vue';
import XQuoteButton from '@/components/MkQuoteButton.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import MkVisibility from '@/components/MkVisibility.vue';
import { pleaseLogin } from '@/scripts/please-login';
import { focusPrev, focusNext } from '@/scripts/focus';
@ -170,7 +148,6 @@ const isRenote = (
const el = ref<HTMLElement>();
const menuButton = ref<HTMLElement>();
const starButton = ref<InstanceType<typeof XStarButton>>();
const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
const renoteTime = ref<HTMLElement>();
const reactButton = ref<HTMLElement>();
@ -187,7 +164,6 @@ const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
const translation = ref(null);
const translating = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).slice(0, 5) : null;
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
const keymap = {
'r': () => reply(true),
@ -229,14 +205,6 @@ function react(viaKeyboard = false): void {
});
}
function undoReact(note): void {
const oldReaction = note.myReaction;
if (!oldReaction) return;
os.api('notes/reactions/delete', {
noteId: note.id,
});
}
const currentClipPage = inject<Ref<misskey.entities.Clip> | null>('currentClipPage', null);
function onContextmenu(ev: MouseEvent): void {
@ -342,9 +310,13 @@ function readPromo() {
}
}
&:hover > .article > .main > .footer > .button {
& > .article > .main {
&:hover, &:focus-within {
:deep(.footer .button) {
opacity: 1;
}
}
}
> .reply-to {
& + .note-context {
@ -352,9 +324,9 @@ function readPromo() {
content: "";
display: block;
margin-bottom: -10px;
width: 2px;
background-color: var(--divider);
margin-inline: auto;
margin-top: 16px;
border-left: 2px solid var(--divider);
margin-left: calc((var(--avatarSize) / 2) - 1px);
}
}
}
@ -477,7 +449,6 @@ function readPromo() {
> .body {
margin-top: .7em;
overflow: hidden;
> .cw {
cursor: default;
@ -585,6 +556,10 @@ function readPromo() {
padding: 16px;
border: solid 1px var(--renote);
border-radius: 8px;
transition: background .2s;
&:hover, &:focus-within {
background-color: var(--panelHighlight);
}
}
}
}
@ -594,36 +569,6 @@ function readPromo() {
font-size: 80%;
}
}
> .footer {
display: flex;
flex-wrap: wrap;
> .button {
margin: 0;
padding: 8px;
opacity: 0.7;
flex-grow: 1;
max-width: 3.5em;
width: max-content;
min-width: max-content;
&:first-of-type {
margin-left: -.5em;
}
&:hover {
color: var(--fgHighlighted);
}
> .count {
display: inline;
margin: 0 0 0 8px;
opacity: 0.7;
}
&.reacted {
color: var(--accent);
}
}
}
}
}

View File

@ -53,7 +53,7 @@
<XCwButton v-model="showContent" :note="appearNote"/>
</p>
<div v-show="appearNote.cw == null || showContent" class="content">
<div class="text" @click.self="router.push(notePage(appearNote))">
<div class="text">
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
<div v-if="translating || translation" class="translation">
<MkLoading v-if="translating" mini/>
@ -68,7 +68,7 @@
</div>
<XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" class="url-preview"/>
<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div>
<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote" @click.stop="router.push(notePage(appearNote.renote))"/></div>
</div>
<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
</div>
@ -291,17 +291,17 @@ function blur() {
os.api('notes/children', {
noteId: appearNote.id,
limit: 30,
depth: 6,
depth: 12,
}).then(res => {
replies.value = res;
directReplies.value = res.filter(note => note.replyId === appearNote.id || note.renoteId === appearNote.id);
directReplies.value = res.filter(note => note.replyId === appearNote.id || note.renoteId === appearNote.id).reverse();
});
if (appearNote.replyId) {
os.api('notes/conversation', {
noteId: appearNote.replyId,
}).then(res => {
conversation.value = res.reverse();
conversation.value = res;
});
}
@ -335,7 +335,6 @@ onUnmounted(() => {
.lxwezrsl {
position: relative;
transition: box-shadow 0.1s ease;
overflow: hidden;
contain: content;
&:focus-visible {
@ -429,7 +428,12 @@ onUnmounted(() => {
> .article {
padding: 32px;
padding-bottom: 6px;
&:last-child {
padding-bottom: 24px;
}
font-size: 1.2em;
overflow: clip;
> .header {
display: flex;
@ -530,6 +534,10 @@ onUnmounted(() => {
padding: 16px;
border: solid 1px var(--renote);
border-radius: 8px;
transition: background .2s;
&:hover, &:focus-within {
background-color: var(--panelHighlight);
}
}
}
}
@ -577,19 +585,65 @@ onUnmounted(() => {
> .reply {
border-top: solid 0.5px var(--divider);
cursor: pointer;
padding-top: 24px;
padding-bottom: 10px;
@media (pointer: coarse) {
cursor: default;
}
}
> .reply, .reply-to, .reply-to-more {
transition: background-color 0.25s ease-in-out;
// Hover
.reply :deep(.main), .reply-to, .reply-to-more, :deep(.more) {
position: relative;
&::before {
content: "";
position: absolute;
inset: -12px -24px;
bottom: -0px;
background: var(--panelHighlight);
border-radius: var(--radius);
opacity: 0;
transition: opacity .2s;
z-index: -1;
}
&.reply-to, &.reply-to-more {
&::before {
inset: 0px 8px;
}
&:first-of-type::before {
top: 12px;
}
}
// &::after {
// content: "";
// position: absolute;
// inset: -9999px;
// background: var(--modalBg);
// opacity: 0;
// z-index: -2;
// pointer-events: none;
// transition: opacity .2s;
// }
&.more::before {
inset: 0 !important;
}
&:hover, &:focus-within {
&::before {
opacity: 1;
}
}
// @media (pointer: coarse) {
// &:has(.button:focus-within) {
// z-index: 2;
// --X13: transparent;
// &::after {
// opacity: 1;
// backdrop-filter: var(--modalBgFilter);
// }
// }
// }
}
&:hover {
background-color: var(--panelHighlight);
}
}
&.max-width_500px {
font-size: 0.9em;

View File

@ -0,0 +1,164 @@
<template>
<footer ref="el" class="footer" @click.stop>
<XReactionsViewer ref="reactionsViewer" :note="appearNote"/>
<button v-tooltip.noDelay.bottom="i18n.ts.reply" class="button _button" @click="reply()">
<i class="ph-arrow-u-up-left ph-bold ph-lg"></i>
<template v-if="directReplies > 0">
<p class="count">{{ directReplies }}</p>
</template>
<template v-else-if="appearNote.repliesCount > 0">
<p class="count">{{ appearNote.repliesCount }}</p>
</template>
</button>
<XRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/>
<XStarButton v-if="appearNote.myReaction == null" ref="starButton" class="button" :note="appearNote"/>
<button v-if="appearNote.myReaction == null" ref="reactButton" v-tooltip.noDelay.bottom="i18n.ts.reaction" class="button _button" @click="react()">
<i class="ph-smiley ph-bold ph-lg"></i>
</button>
<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)">
<i class="ph-minus ph-bold ph-lg"></i>
</button>
<XQuoteButton class="button" :note="appearNote"/>
<button ref="menuButton" v-tooltip.noDelay.bottom="i18n.ts.more" class="button _button" @click="menu()">
<i class="ph-dots-three-outline ph-bold ph-lg"></i>
</button>
</footer>
</template>
<script lang="ts" setup>
import { inject, ref } from 'vue';
import type { Ref } from 'vue';
import type * as misskey from 'calckey-js';
import XReactionsViewer from '@/components/MkReactionsViewer.vue';
import XStarButton from '@/components/MkStarButton.vue';
import XRenoteButton from '@/components/MkRenoteButton.vue';
import XQuoteButton from '@/components/MkQuoteButton.vue';
import { pleaseLogin } from '@/scripts/please-login';
import * as os from '@/os';
import { reactionPicker } from '@/scripts/reaction-picker';
import { i18n } from '@/i18n';
import { getNoteMenu } from '@/scripts/get-note-menu';
import { deepClone } from '@/scripts/clone';
import { useNoteCapture } from '@/scripts/use-note-capture';
const props = defineProps<{
note: misskey.entities.Note;
directReplies;
}>();
let note = $ref(deepClone(props.note));
const isRenote = (
note.renote != null &&
note.text == null &&
note.fileIds.length === 0 &&
note.poll == null
);
const el = ref<HTMLElement>();
const menuButton = ref<HTMLElement>();
const starButton = ref<InstanceType<typeof XStarButton>>();
const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
const reactButton = ref<HTMLElement>();
let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note);
const isDeleted = ref(false);
const translation = ref(null);
const translating = ref(false);
useNoteCapture({
rootEl: el,
note: $$(appearNote),
isDeletedRef: isDeleted,
});
function reply(viaKeyboard = false): void {
pleaseLogin();
os.post({
reply: appearNote,
animation: !viaKeyboard,
}, () => {
focus();
});
}
function react(viaKeyboard = false): void {
pleaseLogin();
blur();
reactionPicker.show(reactButton.value, reaction => {
os.api('notes/reactions/create', {
noteId: appearNote.id,
reaction: reaction,
});
}, () => {
focus();
});
}
function undoReact(note): void {
const oldReaction = note.myReaction;
if (!oldReaction) return;
os.api('notes/reactions/delete', {
noteId: note.id,
});
}
const currentClipPage = inject<Ref<misskey.entities.Clip> | null>('currentClipPage', null);
function menu(viaKeyboard = false): void {
os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), menuButton.value, {
viaKeyboard,
}).then(focus);
}
function focus() {
el.value.focus();
}
function blur() {
el.value.blur();
}
</script>
<style lang="scss" scoped>
.footer {
position: relative;
z-index: 2;
display: flex;
flex-wrap: wrap;
pointer-events: none; // Allow clicking anything w/out pointer-events: all; to open post
> .button {
margin: 0;
padding: 8px;
opacity: 0.7;
flex-grow: 1;
max-width: 3.5em;
width: max-content;
min-width: max-content;
pointer-events: all;
transition: opacity .2s;
&:first-of-type {
margin-left: -.5em;
}
&:hover {
color: var(--fgHighlighted);
}
> .count {
display: inline;
margin: 0 0 0 8px;
opacity: 0.7;
}
&.reacted {
color: var(--accent);
}
}
}
</style>

View File

@ -2,7 +2,7 @@
<header class="kkwtjztg">
<div class="user-info">
<div>
<MkA v-user-preview="note.user.id" class="name" :to="userPage(note.user)">
<MkA v-user-preview="note.user.id" class="name" :to="userPage(note.user)" @click.stop>
<MkUserName :user="note.user" class="mkusername">
<span v-if="note.user.isBot" class="is-bot">bot</span>
</MkUserName>
@ -47,6 +47,8 @@ const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultS
<style lang="scss" scoped>
.kkwtjztg {
position: relative;
z-index: 2;
display: flex;
align-items: center;
white-space: nowrap;

View File

@ -1,9 +1,10 @@
<template>
<div v-size="{ max: [450] }" class="wrpstxzv" :class="{ children: depth > 1 }">
<div v-size="{ max: [450, 500] }" class="wrpstxzv" :class="{ children: depth > 1, singleStart: replies.length == 1, firstColumn: depth == 1 && conversation }">
<div v-if="conversation && depth > 1" class="line"></div>
<div class="main" @click="router.push(notePage(note))">
<div class="avatar-container">
<MkAvatar class="avatar" :user="note.user"/>
<div class="line"></div>
<div v-if="(!conversation) || replies.length > 0" class="line"></div>
</div>
<div class="body">
<XNoteHeader class="header" :note="note" :mini="true"/>
@ -13,16 +14,21 @@
<XCwButton v-model="showContent" :note="note"/>
</p>
<div v-show="note.cw == null || showContent" class="content" @click="router.push(notePage(note))">
<MkSubNoteContent class="text" :note="note"/>
<MkSubNoteContent class="text" :note="note" :detailed="true"/>
</div>
</div>
<MkNoteFooter :note="note" :directReplies="replies.length"></MkNoteFooter>
</div>
</div>
<template v-if="conversation">
<template v-if="depth < 5">
<template v-if="replies.length == 1">
<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply single" :conversation="conversation" :depth="depth"/>
</template>
<template v-else-if="depth < 5">
<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :conversation="conversation" :depth="depth + 1"/>
</template>
<div v-else-if="replies.length > 0" class="more">
<div class="line"></div>
<MkA class="text _link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="ph-caret-double-right ph-bold ph-lg"></i></MkA>
</div>
</template>
@ -35,6 +41,7 @@ import * as misskey from 'calckey-js';
import XNoteHeader from '@/components/MkNoteHeader.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
import XCwButton from '@/components/MkCwButton.vue';
import MkNoteFooter from '@/components/MkNoteFooter.vue';
import { notePage } from '@/filters/note';
import { useRouter } from '@/router';
import * as os from '@/os';
@ -53,16 +60,15 @@ const props = withDefaults(defineProps<{
});
let showContent = $ref(false);
const replies: misskey.entities.Note[] = props.conversation?.filter(item => item.replyId === props.note.id || item.renoteId === props.note.id) ?? [];
const replies: misskey.entities.Note[] = props.conversation?.filter(item => item.replyId === props.note.id || item.renoteId === props.note.id).reverse() ?? [];
</script>
<style lang="scss" scoped>
.wrpstxzv {
padding: 16px 32px;
&.children {
padding: 10px 0 0 16px;
padding: 10px 0 0 var(--indent);
padding-left: var(--indent) !important;
font-size: 1em;
cursor: auto;
@ -71,6 +77,7 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
}
}
> .main {
display: flex;
@ -89,6 +96,9 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
flex: 1;
min-width: 0;
cursor: pointer;
margin: 0 -200px;
padding: 0 200px;
overflow: clip;
@media (pointer: coarse) {
cursor: default;
}
@ -120,14 +130,66 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
}
}
}
&:first-child > .main > .body {
margin-top: -200px;
padding-top: 200px;
}
&.reply {
--avatarSize: 38px;
.avatar-container {
margin-right: 8px !important;
}
:deep(.footer) {
font-size: .9em;
}
}
> .reply, > .more {
border-left: solid 0.5px var(--divider);
margin-top: 10px;
&.single {
padding: 0 !important;
> .line {
display: none;
}
}
}
> .more {
padding: 10px 0 0 16px;
display: flex;
padding-block: 10px;
font-weight: 600;
> .line {
flex-grow: 0 !important;
margin-top: -10px !important;
margin-bottom: 10px !important;
margin-right: 10px !important;
&::before {
border-left-style: dashed !important;
border-bottom-left-radius: 100px !important;
}
}
i {
font-size: 1em !important;
vertical-align: middle !important;
}
a {
position: static;
&::before {
content: "";
position: absolute;
inset: 0;
}
&::after {
content: unset;
}
}
}
&.reply, &.reply-to, &.reply-to-more {
> .main:hover, > .main:focus-within {
:deep(.footer .button) {
opacity: 1;
}
}
}
&.reply-to, &.reply-to-more {
@ -135,7 +197,16 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
&:first-child {
padding-top: 30px;
}
.avatar-container {
.line::before {
margin-bottom: -16px;
}
}
// Reply Lines
&.reply, &.reply-to, &.reply-to-more {
--indent: calc(var(--avatarSize) - 5px);
> .main {
> .avatar-container {
display: flex;
flex-direction: column;
align-items: center;
@ -146,30 +217,90 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
height: var(--avatarSize);
margin: 0;
}
> .line {
}
}
.line {
position: relative;
width: var(--avatarSize);
display: flex;
flex-grow: 1;
margin-bottom: -10px;
&::before {
content: "";
display: block;
width: 2px;
background-color: var(--divider);
margin-inline: auto;
.note > & {
margin-bottom: -16px;
position: absolute;
border-left: 2px solid var(--X13);
margin-left: calc((var(--avatarSize) / 2) - 1px);
width: calc(var(--indent) / 2);
inset-block: 0;
min-height: 8px;
}
}
}
&.reply-to, &.reply-to-more {
> .main > .avatar-container > .line {
margin-bottom: 0px !important;
}
> .main > .body {
padding-bottom: 16px;
}
&.single, &.singleStart {
> .main > .avatar-container > .line {
margin-bottom: -10px !important;
}
}
.reply.children:not(:last-child) { // Line that goes through multiple replies
position: relative;
> .line {
position: absolute;
top: 0;
left: 0;
bottom: 0;
}
}
// Reply line connectors
.reply.children:not(.single) {
position: relative;
> .line {
position: absolute;
left: 0;
top: 0;
&::after {
content: "";
position: absolute;
border-left: 2px solid var(--X13);
border-bottom: 2px solid var(--X13);
margin-left: calc((var(--avatarSize) / 2) - 1px);
width: calc(var(--indent) / 2);
height: calc((var(--avatarSize) / 2));
border-bottom-left-radius: calc(var(--indent) / 2);
top: 8px;
}
}
&:not(:last-child) > .line::after {
mask: linear-gradient(to right, transparent 2px, black 2px);
-webkit-mask: linear-gradient(to right, transparent 2px, black 2px);
}
}
&.max-width_500px {
:not(.reply) > & {
.reply {
--avatarSize: 24px;
--indent: calc(var(--avatarSize) - 4px);
}
}
&.firstColumn {
> .main, > .line, > .children:not(.single) > .line {
--avatarSize: 35px;
--indent: 35px;
}
> .children:not(.single) {
padding-left: 28px !important;
}
}
}
&.max-width_450px {
padding: 14px 16px;
&.reply-to, &.reply-to-more {
padding: 14px 16px;
padding-top: 14px !important;
padding-bottom: 0 !important;
margin-bottom: 0 !important;

View File

@ -1,7 +1,7 @@
<template>
<div class="tivcixzd" :class="{ done: closed || isVoted }">
<ul>
<li v-for="(choice, i) in note.poll.choices" :key="i" :class="{ voted: choice.voted }" @click="vote(i)">
<li v-for="(choice, i) in note.poll.choices" :key="i" :class="{ voted: choice.voted }" @click.stop="vote(i)">
<div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
<span>
<template v-if="choice.isVoted"><i class="ph-check ph-bold ph-lg"></i></template>
@ -13,7 +13,7 @@
<p v-if="!readOnly">
<span>{{ i18n.t('_poll.totalVotes', { n: total }) }}</span>
<span> · </span>
<a v-if="!closed && !isVoted" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a>
<a v-if="!closed && !isVoted" @click.stop="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a>
<span v-if="isVoted">{{ i18n.ts._poll.voted }}</span>
<span v-else-if="closed">{{ i18n.ts._poll.closed }}</span>
<span v-if="remaining > 0"> · {{ timer }}</span>

View File

@ -22,7 +22,7 @@
<span v-if="visibility === 'specified'"><i class="ph-envelope-simple-open ph-bold ph-lg"></i></span>
</button>
<button v-tooltip="i18n.ts.previewNoteText" class="_button preview" :class="{ active: showPreview }" @click="showPreview = !showPreview"><i class="ph-file-code ph-bold ph-lg"></i></button>
<button class="submit _buttonGradate" :disabled="!canPost" data-cy-open-post-form-submit @click="post">{{ submitText }}<i :class="reply ? 'ph-arrow-bend-up-left ph-bold ph-lg' : renote ? 'ph-quotes ph-bold ph-lg' : 'ph-paper-plane-tilt ph-bold ph-lg'"></i></button>
<button class="submit _buttonGradate" :disabled="!canPost" data-cy-open-post-form-submit @click="post">{{ submitText }}<i :class="reply ? 'ph-arrow-u-up-left ph-bold ph-lg' : renote ? 'ph-quotes ph-bold ph-lg' : 'ph-paper-plane-tilt ph-bold ph-lg'"></i></button>
</div>
</header>
<div class="form" :class="{ fixed }">
@ -796,6 +796,8 @@ onMounted(() => {
}
> .submit {
display: inline-flex;
align-items: center;
margin: 16px 16px 16px 0;
padding: 0 12px;
line-height: 34px;

View File

@ -4,7 +4,7 @@
ref="buttonRef"
v-ripple="canToggle"
class="hkzvhatu _button"
:class="{ reacted: note.myReaction == reaction, canToggle }"
:class="{ reacted: note.myReaction == reaction, canToggle, newlyAdded: !isInitial }"
@click="toggleReaction()"
>
<XReactionIcon class="icon" :reaction="reaction" :custom-emojis="note.emojis"/>
@ -13,7 +13,7 @@
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue';
import { computed, ref } from 'vue';
import * as misskey from 'calckey-js';
import XDetails from '@/components/MkReactionsViewer.details.vue';
import XReactionIcon from '@/components/MkReactionIcon.vue';
@ -55,20 +55,6 @@ const toggleReaction = () => {
}
};
const anime = () => {
if (document.hidden) return;
// TODO:
};
watch(() => props.count, (newCount, oldCount) => {
if (oldCount < newCount) anime();
});
onMounted(() => {
if (!props.isInitial) anime();
});
useTooltip(buttonRef, async (showing) => {
const reactions = await os.apiGet('notes/reactions', {
noteId: props.note.id,
@ -97,7 +83,25 @@ useTooltip(buttonRef, async (showing) => {
margin: 2px;
padding: 0 6px;
border-radius: 4px;
pointer-events: all;
&.newlyAdded {
animation: scaleInSmall .3s cubic-bezier(0,0,0,1.2);
:deep(.mk-emoji) {
animation: scaleIn .4s cubic-bezier(0.7, 0, 0, 1.5);
}
}
:deep(.mk-emoji) {
transition: transform .4s cubic-bezier(0,0,0,6);
}
&.reacted :deep(.mk-emoji) {
transition: transform .4s cubic-bezier(0,0,0,1);
}
&:active {
:deep(.mk-emoji) {
transition: transform .4s cubic-bezier(0,0,0,1);
transform: scale(.85);
}
}
&.canToggle {
background: rgba(0, 0, 0, 0.05);
@ -119,6 +123,7 @@ useTooltip(buttonRef, async (showing) => {
> .count {
color: var(--fgOnAccent);
font-weight: 600;
}
> .icon {

View File

@ -21,7 +21,8 @@ const isMe = computed(() => $i && $i.id === props.note.userId);
<style lang="scss" scoped>
.tdflqwzn {
margin: 4px -2px 0 -2px;
margin-inline: -2px;
margin-top: .2em;
width: 100%;
&:empty {

View File

@ -6,18 +6,23 @@
<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
<MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">{{ i18n.ts.quoteAttached }}: ...</MkA>
</div>
<template v-if="detailed">
<!-- <div v-if="note.renoteId" class="renote">
<XNoteSimple :note="note.renote"/>
</div> -->
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/>
</template>
<div v-if="note.files.length > 0">
<summary>({{ i18n.t('withNFiles', { n: note.files.length }) }})</summary>
<XMediaList :media-list="note.files"/>
</div>
<div v-if="note.poll">
<summary>{{ i18n.ts.poll }}</summary>
<XPoll :note="note"/>
</div>
<button v-if="isLong && collapsed" class="fade _button" @click.stop.prevent="collapsed = false">
<button v-if="isLong && collapsed" class="fade _button" @click.stop="collapsed = false">
<span>{{ i18n.ts.showMore }}</span>
</button>
<button v-if="isLong && !collapsed" class="showLess _button" @click.stop.prevent="collapsed = true">
<button v-if="isLong && !collapsed" class="showLess _button" @click.stop="collapsed = true">
<span>{{ i18n.ts.showLess }}</span>
</button>
</div>
@ -26,15 +31,19 @@
<script lang="ts" setup>
import { } from 'vue';
import * as misskey from 'calckey-js';
import * as mfm from 'mfm-js';
import XNoteSimple from '@/components/MkNoteSimple.vue';
import XMediaList from '@/components/MkMediaList.vue';
import XPoll from '@/components/MkPoll.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
import { i18n } from '@/i18n';
const props = defineProps<{
note: misskey.entities.Note;
detailed?: boolean;
}>();
const isLong = (
props.note.cw == null && props.note.text != null && (
(props.note.text.split('\n').length > 9) ||
@ -42,6 +51,8 @@ const isLong = (
)
);
const collapsed = $ref(props.note.cw == null && isLong);
const urls = props.note.text ? extractUrlFromMfm(mfm.parse(props.note.text)) : null;
</script>
<style lang="scss" scoped>
@ -61,6 +72,10 @@ const collapsed = $ref(props.note.cw == null && isLong);
}
}
> .mk-url-preview {
margin-top: 8px;
}
&.collapsed {
position: relative;
max-height: 9em;

View File

@ -1,12 +1,12 @@
<template>
<div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`">
<div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`" @click.stop>
<button class="disablePlayer" :title="i18n.ts.disablePlayer" @click="playerEnabled = false"><i class="ph-x ph-bold ph-lg"></i></button>
<iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
</div>
<div v-else-if="tweetId && tweetExpanded" ref="twitter" class="twitter">
<div v-else-if="tweetId && tweetExpanded" ref="twitter" class="twitter" @click.stop>
<iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"></iframe>
</div>
<div v-else v-size="{ max: [400, 350] }" class="mk-url-preview">
<div v-else v-size="{ max: [400, 350] }" class="mk-url-preview" @click.stop>
<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
<component :is="self ? 'MkA' : 'a'" v-if="!fetching" class="link" :class="{ compact }" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url">
<div v-if="thumbnail" class="thumbnail" :style="`background-image: url('${thumbnail}')`">
@ -214,9 +214,10 @@ onUnmounted(() => {
border: 1px solid var(--divider);
border-radius: 8px;
overflow: hidden;
&:hover {
transition: background .2s;
&:hover, &:focus-within {
text-decoration: none;
background-color: var(--panelHighlight);
> article > header > h1 {
text-decoration: underline;
}

View File

@ -3,7 +3,7 @@
<img class="inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="showIndicator && user.instance == null" class="indicator" :user="user"/>
</span>
<MkA v-else v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :to="userPage(user)" :title="acct(user)" :target="target">
<MkA v-else v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :to="userPage(user)" :title="acct(user)" :target="target" @click.stop>
<img class="inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="showIndicator && user.instance == null" class="indicator" :user="user"/>
</MkA>

View File

@ -1,7 +1,7 @@
<template>
<component
:is="self ? 'MkA' : 'a'" ref="el" class="ieqqeuvs _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
@contextmenu.stop="() => {}"
@contextmenu.stop="() => {}" @click.stop
>
<template v-if="!self">
<span class="schema">{{ schema }}//</span>

View File

@ -2,7 +2,7 @@
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="800">
<div class="fcuexfpr">
<div class="fcuexfpr" v-size="{ max: [500, 350] }">
<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
<div v-if="note" class="note">
<div v-if="showNext" class="_gap">
@ -202,5 +202,13 @@ definePageMetadata(computed(() => note ? {
}
}
}
#calckey_app > :not(.mk-deck) {
&.max-width_500px > .note {
margin-inline: -24px;
}
&.max-width_350px > .note {
margin-inline: -12px;
}
}
}
</style>

View File

@ -32,7 +32,7 @@ html {
overflow-wrap: break-word;
font-family: "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif;
font-size: 14px;
line-height: 1.35;
line-height: 1.6;
text-size-adjust: 100%;
tab-size: 2;
@ -155,6 +155,10 @@ hr {
box-shadow: 0px 4px 32px var(--shadow) !important;
}
.swiper {
overflow: clip !important;
}
._button {
appearance: none;
display: inline-block;
@ -479,6 +483,7 @@ hr {
}
._link {
position: relative;
color: var(--link);
&:after {
@ -680,3 +685,19 @@ hr {
width: 1.25em;
display: inline-flex;
}
@media(prefers-reduced-motion: no-preference) {
@keyframes scaleIn {
from {
transform: scale(0);
opacity: 0;
}
}
@keyframes scaleInSmall {
from {
transform: scale(.8);
opacity: 0;
}
}
}