Files
uwuzu-light-client/src/components/Ueuse/index.vue
T
2026-03-30 20:15:26 +09:00

439 lines
12 KiB
Vue

<template>
<div
:class='[
"ueuse",
"flex", "w-full", "h-fit",
"rounded-lg", "gap-2",
]'
v-if="authState.isSignedIn"
ref="ueuseElement"
>
<RouterLink class="shrink-0 h-fit rounded-full" :to="`/@${data.account.userid}`">
<img
:class='[
"w-12", "h-12", "rounded-full", "cursor-pointer",
"block", "shrink-0", "object-cover", "ueuse-user-icon"
]'
:src="data.account.user_icon"
v-if="data.text !== ''"
/>
</RouterLink>
<div class="flex flex-col grow gap-1 min-w-0">
<div class="flex gap-1 ueuse-detail">
<RouterLink
:to="`/@${data.account.userid}`"
class="flex gap-1 ueuse-detail-account truncate"
>
<img
:class='[
"block", "w-6", "h-6", "rounded-full", "mr-1",
"shrink-0", "object-cover", "ueuse-user-icon"
]'
:src="data.account.user_icon"
v-if="isReuse && data.text === ''"
/>
<span class="font-bold ueuse-user-name truncate">
{{ data.account.username }}
</span>
<span class="ueuse-user-id text-(--text-color)/85 truncate">
@{{ data.account.userid }}
</span>
<Icon
icon="material-symbols:robot-2-rounded"
class="w-4 h-4 shrink-0 m-auto opacity-70"
v-if="data.account.is_bot"
/>
<Icon
icon="material-symbols:check-circle-rounded"
class="w-4 h-4 shrink-0 m-auto text-(--accent)"
v-if="/*Botかどうかを毎回取得してたらきりない*/false"
/>
</RouterLink>
<Icon
icon="material-symbols:repeat-rounded"
class="w-4 h-4 shrink-0 my-auto text-(--accent)"
v-if="isReuse && data.text === ''"
/>
<div class="ml-auto w-fit shrink-0">
{{ parsedDate }}
</div>
</div>
<div
:class='[
"flex", "flex-col",
"bg-(--accent)", "text-(--accent-in-text)",
"rounded-lg", "p-2", "gap-1",
"ueuse-nsfw",
]'
v-if="data.nsfw"
>
<div class="flex shrink items-center w-full gap-1">
<Icon
class="my-auto break-all"
icon="material-symbols:visibility-off-rounded"
/>
NSFWユーズ
<Button
@click="visibility = !visibility"
class="px-2! ml-auto text-sm border border-(--text-color)"
>
{{ visibility
? "非表示"
: "表示"
}}
</Button>
</div>
<div class="ueuse-nsfw-detail">
{{ detail }}
</div>
</div>
<div
class="flex flex-col gap-1 ueuse-content"
v-if="visibility"
>
<p class="ueuse-text break-all" v-if="parsedText">
<template v-for="(node, i) in parsedText" :key="i">
<component v-if="typeof node !== 'string'" :is="node" />
<span v-else>{{ node }}</span>
</template>
</p>
<div
class="grid gap-1 ueuse-media"
:class="mediaGridClass"
v-if="medias.length > 0"
>
<template
v-for="(media, i) in medias"
:key="media"
>
<Media
v-if="
i < 2 ||
(medias.length >= 4 &&
i < 4)
"
:src="media"
class="col-span-1 aspect-4/3"
@click="showLgMedia(media)"
/>
<Media
v-else
:src="media"
class="col-span-2 aspect-2/1"
@click="showLgMedia(media)"
/>
</template>
</div>
<div
:class='[
"flex", "flex-col", "p-2", "rounded-lg", "gap-1.5",
"border", "border-(--border-color)", "bg-(--bg-color)/70", "ueuse-abi",
]'
v-if="data.abi !== '' && data.abidatetime !== ''"
>
<span :class='[
"bg-(--accent)", "text-(--accent-in-text)",
"px-2", "py-1.5", "rounded-md", "font-bold", "flex",
]'>
<Icon
class="my-auto mr-1 w-5 h-5"
icon="material-symbols:add-notes-rounded"
/>
追記
</span>
<p class="ueuse-abi-content break-all" v-if="parsedAbiText">
<template v-for="(node, i) in parsedAbiText" :key="i">
<component v-if="typeof node !== 'string'" :is="node" />
<span v-else>{{ node }}</span>
</template>
</p>
<span class="text-sm text-(--text-color)/70">
{{ parsedAbiDate }}
</span>
</div>
<Ueuse
:data="reuseData"
:lgMedia="lgMedia"
:depth="depth + 1"
v-if="isReuse && reuseData"
:class='[
"mt-2",
{
"border": data.text !== "",
"border-(--border-color)": data.text !== "",
"p-2": data.text !== "",
},
]'
/>
</div>
<div
class="mt-2 flex flex-wrap gap-3 ueuse-operation"
v-if="data.text !== ''"
>
<Operation
@click="likeUpdate(data.uniqid)"
name="like"
icon="material-symbols:favorite-outline-rounded"
usedIcon="material-symbols:favorite-rounded"
:isUse="data.favorite.includes(authState.me.userid)"
:data="data.favorite_cnt"
/>
<Operation
name="reuse"
icon="material-symbols:repeat-rounded"
:data="data.reuse_cnt"
/>
<RouterLink
class="rounded-2xl"
:to="`/ueuse/${data.uniqid}`"
>
<Operation
name="reply"
icon="material-symbols:reply-rounded"
:data="data.reply_cnt"
/>
</RouterLink>
<Operation
name="more"
icon="material-symbols:more-horiz"
class="ml-auto"
@click="showMoreMenu"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type ueuseModule from "better-uwuzu-sdk/types/1.6.8/types/modules/ueuse";
import { DateParse, parseTextToNodes } from "@/lib/parser";
import { RouterLink } from "vue-router";
import Button from "@/components/Button.vue";
import Media from "@/components/Media/index.vue";
import Ueuse from "@/components/Ueuse/index.vue";
import Operation from "@/components/Ueuse/Operation.vue";
import { Icon } from "@iconify/vue";
import uwuzu from "better-uwuzu-sdk";
import type ApiMap from "better-uwuzu-sdk/types/1.6.8/map";
import Parser from "better-uwuzu-sdk/1.6.8/parser";
import {
ref, inject, computed, watch,
onMounted, onBeforeUnmount,
type Reactive, type Ref, type Component,
} from "vue";
import { authState } from "@/lib/account";
import waitUntil from "@/lib/wait";
import { ueuseMoreMenu } from "@/components/Ueuse/More.vue";
const props = defineProps<{
data: ueuseModule;
lgMedia: Reactive<{
src: string;
}>;
depth?: number;
}>();
const depth = props.depth ?? 0;
const MAX_DEPTH = ref<number>(1);
const apiClient = ref<uwuzu<ApiMap>>();
const visibility = ref<boolean>(!props.data.nsfw);
const isReuse = props.data.reuseid !== "" && depth < MAX_DEPTH.value;
const reuseData = ref<ueuseModule>();
const now = inject<Ref<Date>>("now")!;
type ParsedNodes = (string | Component)[];
const parsedText = ref<ParsedNodes | null>(null);
const parsedAbiText = ref<ParsedNodes | null>(null);
const ueuseElement = ref<HTMLElement | null>(null);
const parsedDate = ref("");
const parsedAbiDate = ref("");
let observer: IntersectionObserver;
onMounted(() => {
if (!ueuseElement.value) return;
observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting)
continue;
parsedDate.value ||= DateParse(props.data.datetime, now.value);
if (props.data.abidatetime) {
parsedAbiDate.value ||= DateParse(props.data.abidatetime, now.value);
}
if (
visibility.value &&
props.data.text &&
!parsedText.value
) {
parsedText.value = parseTextToNodes(props.data.text);
}
if (
visibility.value &&
props.data.abi &&
props.data.abidatetime &&
!parsedAbiText.value
) {
parsedAbiText.value = parseTextToNodes(props.data.abi);
}
observer.unobserve(entry.target);
}
}, {
rootMargin: "100px 0px",
});
observer.observe(ueuseElement.value);
});
onBeforeUnmount(() => {
observer?.disconnect();
});
watch(visibility, (v) => {
if (!v)
return;
if (props.data.text !== "") {
parsedText.value ||= parseTextToNodes(props.data.text);
}
if (props.data.abi !== "") {
parsedAbiText.value ||= parseTextToNodes(props.data.abi);
}
});
const medias = props.data.media.photo.concat(props.data.media.video);
const mediaGridClass = computed(() => {
switch (medias.length) {
case 1:
return "grid-cols-1";
default:
return "grid-cols-2";
}
});
const detail = computed(() => {
const result: string[] = [];
if (props.data.text.trim() !== "" && props.data.reuseid === "") {
result.push(`${props.data.text.trim().length}文字の本文`);
}
if (medias.length > 0) {
result.push(`${medias.length}枚のメディア`)
}
if (props.data.abi.trim() !== "") {
result.push(`${props.data.abi.trim().length}文字の追記`);
}
if (props.data.replyid !== "") {
result.push("返信");
}
if (props.data.reuseid !== "") {
if (props.data.text === "") {
result.push("リユーズ");
} else {
result.push(`${props.data.text.trim().length}文字の引用`);
}
}
return result.join("・");
});
const showLgMedia = (src: string) => {
const ext = src.slice(src.lastIndexOf(".") + 1).toLowerCase();
const isPhoto = () => {
const videoExts = ["mp4", "mpeg", "mpg", "webm", "avi"];
return !videoExts.includes(ext);
};
if (!isPhoto())
return;
props.lgMedia.src = src;
}
const likeUpdate = async (uniqid: string) => {
await waitUntil(() => apiClient.value !== undefined, 100);
if (!apiClient.value) return false;
if (!authState.isSignedIn) return false;
MAX_DEPTH.value = authState.reuse_maxdepth;
try {
const chg = await apiClient.value.request("favorite/change", {
uniqid: uniqid,
});
if (chg.success) {
props.data.favorite = chg.favorite_list;
props.data.favorite_cnt = chg.favorite_list.length - 1;
}
return chg.success;
} catch {
return false;
}
}
const showMoreMenu = (event: MouseEvent) => {
if (!ueuseMoreMenu.visibility) {
ueuseMoreMenu.uniqid = props.data.uniqid;
ueuseMoreMenu.position = event.pageY;
ueuseMoreMenu.visibility = true;
}
}
(async () => {
if (!authState.isSignedIn)
return;
apiClient.value = new uwuzu<ApiMap>({
origin: authState.origin,
parser: Parser,
});
apiClient.value.token = authState.token;
if (isReuse) {
const ueuse = await apiClient.value.request("ueuse/get", {
uniqid: props.data.reuseid,
});
if (!ueuse.success) {
throw new Error(ueuse.error_code);
}
reuseData.value = ueuse.data[0];
}
})();
</script>