Compare commits

...

7 Commits

Author SHA1 Message Date
Derek ac63accbed [chatui] Right-aligned times 2024-03-17 20:03:16 -04:00
Derek 832e49b29b [chatui] Minor sidebar improvements + cleanup 2024-03-17 18:55:52 -04:00
Derek e3035ea18b [chatui] Improve readability of renotes and replies 2024-03-17 17:59:24 -04:00
Derek 03a7a4e56d [chatui] Small CW buttons 2024-03-17 15:41:14 -04:00
Derek c0013b6c88 [chatui] Channel colors 2024-03-17 15:40:51 -04:00
Derek de403aca9d [chatui] Only enumerate favorited channels 2024-03-17 14:39:05 -04:00
Derek a6d6e778b0 [chatui] Add missing nasubi channel features
I'd like to have pinned actually be pinned but ill work that out later
2024-03-17 14:39:02 -04:00
9 changed files with 688 additions and 351 deletions

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkButton rounded full small @click="toggle"><b>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}</b><span v-if="!modelValue" :class="$style.label">{{ label }}</span></MkButton>
<MkButton rounded :full="!props.small" small @click="toggle"><b>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}</b><span v-if="!modelValue" :class="$style.label">{{ label }}</span></MkButton>
</template>
<script lang="ts" setup>
@ -21,6 +21,7 @@ const props = defineProps<{
renote?: Misskey.entities.Note | null;
files?: Misskey.entities.DriveFile[];
poll?: Misskey.entities.Note['poll'] | PollEditorModelValue | null;
small?: boolean | null;
}>();
const emit = defineEmits<{

View File

@ -36,6 +36,7 @@ import { deviceKind } from '@/scripts/device-kind.js';
const props = withDefaults(defineProps<{
src?: HTMLElement;
anchor?: { x: string; y: string; };
all?: boolean | null;
}>(), {
anchor: () => ({ x: 'right', y: 'center' }),
});
@ -52,7 +53,7 @@ const modal = shallowRef<InstanceType<typeof MkModal>>();
const menu = defaultStore.state.menu;
const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => navbarItemDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({
const items = Object.keys(navbarItemDef).filter(k => props.all || !menu.includes(k)).map(k => navbarItemDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({
type: def.to ? 'link' : 'button',
text: def.title,
icon: def.icon,

View File

@ -24,16 +24,10 @@
<MkA to="/#global" v-if="isGlobalTimelineAvailable" class="item" active-class="active" exact><i class="ti ti-whirl icon"></i>{{ i18n.ts._timelines.global }}</MkA>
</div>
</div>
<div v-if="followedChannels" class="container">
<div class="header">{{ i18n.ts.channel }} ({{ i18n.ts.following }})<button class="_button add" @click="addChannel"><i class="ti ti-plus"></i></button></div>
<div v-if="favoriteChannels" class="container">
<div class="header">{{ i18n.ts.channel }} ({{ i18n.ts.favorites }})<MkA to="/channels" class="_button add"><i class="ti ti-dots"></i></MkA></div>
<div class="body">
<MkA v-for="channel in followedChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ read: !channel.hasUnreadNote }" active-class="active" exact><i class="ti ti-device-tv icon"></i>{{ channel.name }}</MkA>
</div>
</div>
<div v-if="featuredChannels" class="container">
<div class="header">{{ i18n.ts.channel }}<button class="_button add" @click="addChannel"><i class="ti ti-plus"></i></button></div>
<div class="body">
<MkA v-for="channel in featuredChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" active-class="active" exact><i class="ti ti-device-tv icon"></i>{{ channel.name }}</MkA>
<MkA v-for="channel in favoriteChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ read: !channel.hasUnreadNote }" active-class="active" exact><i class="ti ti-device-tv icon"></i>{{ channel.name }}</MkA>
</div>
</div>
<div v-if="lists" class="container">
@ -43,7 +37,7 @@
</div>
</div>
<div v-if="antennas" class="container">
<div class="header">{{ i18n.ts.antennas }}<button class="_button add" @click="addAntenna"><i class="ti ti-plus"></i></button></div>
<div class="header">{{ i18n.ts.antennas }}<MkA to="/my/antennas/create" class="_button add"><i class="ti ti-plus"></i></MkA></div>
<div class="body">
<MkA v-for="antenna in antennas" :key="antenna.id" :to="`/timeline/antenna/${ antenna.id }`" class="item" active-class="active" exact><i class="ti ti-antenna icon"></i>{{ antenna.name }}</MkA>
</div>
@ -55,10 +49,14 @@
<button class="_button menu" @click="showMenu">
<i class="ti ti-menu-2 icon"></i>
</button>
</div>
<div class="right">
<button v-tooltip="i18n.ts.search" class="_button item search" @click="search">
<i class="ti ti-search icon"></i>
<MkA v-if="$i.isAdmin || $i.isModerator" v-tooltip="i18n.ts.controlPanel" class="item" to="/admin">
<i class="ti ti-dashboard icon"></i>
</MkA>
<button v-tooltip="i18n.ts.more" class="item _button" @click="more">
<i class="ti ti-grid-dots icon"></i>
</button>
<MkA v-tooltip="i18n.ts.settings" class="item" to="/settings"><i class="ti ti-settings icon"></i></MkA>
</div>
@ -101,11 +99,9 @@ import { provide, ref, shallowRef, toRefs, computed, onMounted, defineAsyncCompo
import { instanceName, url } from '@/config.js';
import XWidgets from './chat/widgets.vue';
import XCommon from './_common_/common.vue';
import XHeaderClock from './chat/header-clock.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { search } from '@/scripts/search.js';
import makeList from '@/scripts/new-list.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
@ -116,9 +112,11 @@ import { mainRouter } from '@/router/main.js';
import { defaultRoutes, page } from '@/router/definition.js';
import { useRouterFactory } from '@/router/supplier.js';
const XDrawerMenu = defineAsyncComponent(() => import('@/ui/_common_/navbar-for-mobile.vue'));
const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue'));
const XLaunchPad = defineAsyncComponent(() => import('@/components/MkLaunchPad.vue'));
const routes = [
{
@ -129,6 +127,9 @@ const routes = [
globalCacheKey: 'index',
}, {
...defaultRoutes.find((route) => route.path == '/channels/new'),
}, {
path: '/channels/:channelId/about',
component: page(() => import('./chat/pages/channels.about.vue')),
}, {
path: '/channels/:channelId',
props: { tlSrc: 'channel' },
@ -192,8 +193,7 @@ const menu = computed(() => [{
const lists = ref([]);
const antennas = ref([]);
const followedChannels = ref([]);
const featuredChannels = ref([]);
const favoriteChannels = ref([]);
function getSidebarContent() {
misskeyApi('users/lists/list').then(newLists => {
@ -204,22 +204,11 @@ function getSidebarContent() {
antennas.value = newAntennas;
});
misskeyApi('channels/followed', { limit: 20 }).then(channels => {
followedChannels.value = channels;
});
// TODO: pagination
misskeyApi('channels/featured', { limit: 20 }).then(channels => {
featuredChannels.value = channels;
misskeyApi('channels/my-favorites').then(channels => {
favoriteChannels.value = channels;
});
}
function addChannel() {
chatRouter.push('/channels/new');
}
function addAntenna() {
chatRouter.push('/my/antennas/create');
}
async function addList() {
const success = await makeList();
if (success) {
@ -235,6 +224,13 @@ function hideMenu() {
drawerMenuShowing.value = false;
}
function more(ev: MouseEvent) {
os.popup(XLaunchPad, {
src: ev.currentTarget ?? ev.target,
all: true,
}, {}, 'closed');
}
function post() {
os.post();
}

View File

@ -1,61 +0,0 @@
<template>
<div class="acemodlh _monospace">
<div>
<span v-text="y"></span>/<span v-text="m"></span>/<span v-text="d"></span>
</div>
<div>
<span v-text="hh"></span>
<span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
<span v-text="mm"></span>
<span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
<span v-text="ss"></span>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
data() {
return {
clock: null,
y: null,
m: null,
d: null,
hh: null,
mm: null,
ss: null,
showColon: true,
};
},
created() {
this.tick();
this.clock = setInterval(this.tick, 1000);
},
beforeUnmount() {
clearInterval(this.clock);
},
methods: {
tick() {
const now = new Date();
this.y = now.getFullYear().toString();
this.m = (now.getMonth() + 1).toString().padStart(2, '0');
this.d = now.getDate().toString().padStart(2, '0');
this.hh = now.getHours().toString().padStart(2, '0');
this.mm = now.getMinutes().toString().padStart(2, '0');
this.ss = now.getSeconds().toString().padStart(2, '0');
this.showColon = now.getSeconds() % 2 === 0;
}
}
});
</script>
<style lang="scss" scoped>
.acemodlh {
opacity: 0.7;
font-size: 0.85em;
line-height: 1em;
text-align: center;
}
</style>

View File

@ -1,21 +1,28 @@
<template>
<header class="dehvdgxo">
<MkA v-user-preview="note.user.id" class="name" :to="userPage(note.user)">
<MkUserName :user="note.user"/>
</MkA>
<span v-if="note.user.isBot" class="is-bot">bot</span>
<span class="username"><MkAcct :user="note.user"/></span>
<div class="info">
<MkA class="created-at" :to="notePage(note)">
<MkTime :time="note.createdAt"/>
<header :class="$style.root">
<div :class="$style.left">
<MkA v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)">
<MkUserName :user="note.user"/>
</MkA>
<span v-if="note.visibility !== 'public'" class="visibility">
<i v-if="note.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="note.visibility === 'specified'" class="ti ti-mail"></i>
</span>
<span v-if="note.localOnly" class="localOnly"><i class="ti ti-rocket-off"></i></span>
<span v-if="note.channel" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
<span v-if="note.user.isBot" :class="$style.is-bot">bot</span>
<span :class="$style.username"><MkAcct :user="note.user"/></span>
<div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
<img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
</div>
</div>
<div :class="$style.right">
<div :class="$style.info">
<span v-if="note.visibility !== 'public'">
<i v-if="note.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="note.visibility === 'specified'" class="ti ti-mail"></i>
</span>
<span v-if="note.localOnly" :class="$style.localOnly"><i class="ti ti-rocket-off"></i></span>
<span v-if="note.channel" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
<MkA :class="$style.createdAt" :to="notePage(note)">
<MkTime :time="note.createdAt" colored/>
</MkA>
</div>
</div>
</header>
</template>
@ -45,51 +52,69 @@ export default defineComponent({
});
</script>
<style lang="scss" scoped>
.dehvdgxo {
<style lang="scss" module>
.root {
display: flex;
width: 100%;
align-items: baseline;
white-space: nowrap;
font-size: 0.9em;
> .name {
display: block;
margin: 0 .5em 0 0;
padding: 0;
overflow: hidden;
font-size: 1em;
font-weight: bold;
text-decoration: none;
text-overflow: ellipsis;
&:hover {
text-decoration: underline;
}
.left {
display: flex;
margin-right: auto;
}
}
> .is-bot {
flex-shrink: 0;
align-self: center;
margin: 0 .5em 0 0;
padding: 1px 6px;
font-size: 80%;
border: solid 0.5px var(--divider);
border-radius: 3px;
.name {
display: block;
margin: 0 .5em 0 0;
padding: 0;
overflow: hidden;
font-size: 1em;
font-weight: bold;
text-decoration: none;
text-overflow: ellipsis;
&:hover {
text-decoration: underline;
}
}
> .username {
margin: 0 .5em 0 0;
overflow: hidden;
text-overflow: ellipsis;
.isBot {
flex-shrink: 0;
align-self: center;
margin: 0 .5em 0 0;
padding: 1px 6px;
font-size: 80%;
border: solid 0.5px var(--divider);
border-radius: 3px;
}
.username {
margin: 0 .5em 0 0;
overflow: hidden;
text-overflow: ellipsis;
}
.badgeRoles {
margin: 0 .5em 0 0;
}
.badgeRole {
height: 1.3em;
vertical-align: -20%;
& + .badgeRole {
margin-left: 0.2em;
}
}
> .info {
font-size: 0.9em;
opacity: 0.7;
.info {
opacity: 0.7;
> span {
margin-left: 8px;
}
> *:not(:last-child) {
margin-right: 8px;
}
}
</style>

View File

@ -1,113 +1,56 @@
<template>
<div class="wrpstxzv" :class="{ children }">
<div class="main">
<MkAvatar class="avatar" :user="note.user"/>
<div class="body">
<XNoteHeader class="header" :note="note" :mini="true"/>
<div class="body">
<p v-if="note.cw != null" class="cw">
<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
<MkCwButton v-model="showContent" :note="note"/>
</p>
<div v-show="note.cw == null || showContent" class="content">
<XSubNoteContent class="text" :note="note"/>
</div>
</div>
</div>
</div>
<div class="wrpstxzv">
<MkAvatar class="avatar" :user="note.user" link preview/>
<Mfm :text="summary" :plain="true" :nowrap="true" :author="note.user" :nyaize="'respect'" class="text"/>
</div>
</template>
<script lang="ts" setup>
import { ref, shallowRef } from 'vue';
import { ref, shallowRef, watchEffect } from 'vue';
import * as misskey from 'misskey-js';
import XNoteHeader from './note-header.vue';
import XSubNoteContent from './sub-note-content.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import { notePage } from '@/filters/note.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { getNoteSummary } from '@/scripts/get-note-summary.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
const props = withDefaults(defineProps<{
const props = defineProps<{
note: misskey.entities.Note;
detail?: boolean;
children?: boolean;
// how many notes are in between this one and the note being viewed in detail
depth?: number;
}>(), {
depth: 1,
children: false,
showContent?: boolean | null;
}>();
const summary = ref(null);
watchEffect(() => {
if (props.showContent) {
summary.value = getNoteSummary({
...props.note,
cw: null,
});
} else {
summary.value = getNoteSummary(props.note);
}
});
let showContent = ref(false);
let replies: misskey.entities.Note[] = shallowRef([]);
if (props.detail) {
misskeyApi('notes/children', {
noteId: props.note.id,
limit: 5,
}).then(res => {
replies = res;
});
}
</script>
<style lang="scss" scoped>
.wrpstxzv {
padding: 16px 16px;
font-size: 0.8em;
display: flex;
align-items: center;
overflow: hidden;
&.children {
padding: 10px 0 0 16px;
font-size: 1em;
> .avatar {
flex-shrink: 0;
display: block;
margin: 0 8px 0 0;
width: 28px;
height: 28px;
}
> .main {
display: flex;
> .avatar {
flex-shrink: 0;
display: block;
margin: 0 8px 0 0;
width: 36px;
height: 36px;
}
> .body {
flex: 1;
min-width: 0;
> .header {
margin-bottom: 2px;
}
> .body {
> .cw {
cursor: default;
display: block;
margin: 0;
padding: 0;
overflow-wrap: break-word;
> .text {
margin-right: 8px;
}
}
> .content {
> .text {
margin: 0;
padding: 0;
}
}
}
}
}
> .reply {
border-left: solid 0.5px var(--divider);
margin-top: 10px;
> .text {
overflow: hidden;
flex-shrink: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>

View File

@ -5,14 +5,35 @@
ref="rootEl"
v-hotkey="keymap"
class="vfzoeqcg"
:class="{ renote: isRenote, operating: operating }"
:class="{ collapsed: renoteCollapsed, operating: operating }"
:tabindex="!isDeleted ? '-1' : undefined"
>
<XSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" class="reply-to"/>
<div v-if="appearNote.reply && !renoteCollapsed" class="header reply-to">
<MkA class="icon" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-corner-up-right"></i></MkA>
<XSub class="note" :note="appearNote.reply" :showContent="showContent"/>
</div>
<div v-if="pinned" class="info"><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="renote">
<div v-if="isRenote && renoteCollapsed" class="collapsed-renote" @click="renoteCollapsed = false">
<MkAvatar class="avatar" :user="note.user"/>
<i class="icon ti ti-repeat"></i>
<XSub class="note" :note="appearNote"/>
<div class="info">
<span v-if="note.visibility !== 'public'" class="visibility" :title="i18n.ts._visibility[note.visibility]">
<i v-if="note.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
</span>
<span v-if="note.localOnly" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
<span v-if="note.channel" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
<button ref="renoteTime" class="_button time" @click.stop="showRenoteMenu()">
<i class="ti ti-dots dropdownIcon"></i>
<MkTime :time="note.createdAt"/>
</button>
</div>
</div>
<div v-else-if="isRenote" class="header renote-header">
<MkAvatar class="avatar" :user="note.user"/>
<i class="ti ti-repeat"></i>
<I18n :src="i18n.ts.renotedBy" tag="span">
@ -23,10 +44,6 @@
</template>
</I18n>
<div class="info">
<button ref="renoteTime" class="_button time" @click="showRenoteMenu()">
<i class="ti ti-dots dropdownIcon"></i>
<MkTime :time="note.createdAt"/>
</button>
<span v-if="note.visibility !== 'public'" class="visibility" :title="i18n.ts._visibility[note.visibility]">
<i v-if="note.visibility === 'home'" class="ti ti-home"></i>
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
@ -34,14 +51,13 @@
</span>
<span v-if="note.localOnly" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
<span v-if="note.channel" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
<button ref="renoteTime" class="_button time" @click="showRenoteMenu()">
<i class="ti ti-dots dropdownIcon"></i>
<MkTime :time="note.createdAt"/>
</button>
</div>
</div>
<div v-if="renoteCollapsed" class="collapsed-renote">
<MkAvatar class="avatar" :user="appearNote.user" link preview/>
<Mfm :text="getNoteSummary(appearNote)" :plain="true" :nowrap="true" :author="appearNote.user" :nyaize="'respect'" class="text" @click="renoteCollapsed = false"/>
</div>
<article v-else class="article" @contextmenu.stop="onContextmenu">
<!-- <div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div> -->
<article v-if="!renoteCollapsed" class="article" @contextmenu.stop="onContextmenu">
<MkAvatar class="avatar" :user="appearNote.user"/>
<div class="main">
<XNoteHeader class="header" :note="appearNote" :mini="true"/>
@ -49,12 +65,11 @@
<div class="body" style="container-type: inline-size;">
<p v-if="appearNote.cw != null" class="cw">
<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/>
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" :small="true" />
</p>
<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed }">
<div class="text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span>
<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="ti ti-arrow-back-up"></i></MkA>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
<div v-if="translating || translation" class="translation">
<MkLoading v-if="translating" mini/>
@ -80,7 +95,7 @@
<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="ti ti-device-tv"></i> {{ appearNote.channel.name }}</MkA>
</div>
<MkReactionsViewer :note="appearNote"/>
<footer class="footer _panel">
<footer class="footer">
<button v-tooltip="i18n.ts.reply" class="button _button" @click="reply()">
<i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p>
@ -246,6 +261,12 @@ const renoteCollapsed = ref(
);
const operating = ref(false);
onMounted(() => {
if (appearNote.value.channel && rootEl.value && !inChannel.value) {
rootEl.value.style.setProperty('--channelColor', appearNote.value.channel.color);
}
});
/* Overload FunctionLint
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute';
@ -561,15 +582,32 @@ function emitUpdReaction(emoji: string, delta: number) {
&:hover, &.operating {
> .article > .main > .footer {
display: block;
opacity: 1;
}
&::before {
opacity: 0;
}
}
&.renote {
background: rgba(128, 255, 0, 0.05);
&::before {
content: "";
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: -1;
background: var(--channelColor);
opacity: 0.15;
filter: saturate(75%);
transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
}
&.highlighted {
background: rgba(255, 128, 0, 0.05);
&::after {
content: "";
position: absolute;
top: 0; left: 0;
width: 5px; height: 100%;
z-index: -1;
background: var(--channelColor);
}
> .info {
@ -596,19 +634,58 @@ function emitUpdReaction(emoji: string, delta: number) {
padding-top: 8px;
}
> .reply-to {
opacity: 0.7;
padding-bottom: 0;
}
> .renote, > .collapsed-renote {
> .collapsed-renote {
display: flex;
align-items: center;
padding-bottom: 8px;
overflow: hidden;
gap: 0.5em;
padding: 12px 16px 12px 16px;
> .avatar {
flex-shrink: 0;
display: inline-block;
width: 28px;
height: 28px;
border-radius: 6px;
}
> .icon {
color: var(--renote);
}
> .note {
opacity: 0.7;
font-size: 0.9em;
}
}
> .header {
padding: 12px 16px 0px 16px;
line-height: 28px;
}
> .reply-to {
display: flex;
align-items: center;
> .icon {
color: var(--mention);
margin-right: 0.5em;
}
> .note {
opacity: 0.7;
font-size: 0.9em;
}
}
> .renote-header {
display: flex;
align-items: center;
padding: 12px 16px 4px 16px;
line-height: 28px;
white-space: pre;
color: var(--renote);
font-size: 0.9em;
> .avatar {
flex-shrink: 0;
@ -620,7 +697,7 @@ function emitUpdReaction(emoji: string, delta: number) {
}
> i {
margin-right: 4px;
margin-right: 0.5em;
}
> span {
@ -633,11 +710,16 @@ function emitUpdReaction(emoji: string, delta: number) {
font-weight: bold;
}
}
}
> .renote-header, > .collapsed-renote {
color: var(--renote);
> .info {
margin-left: 8px;
margin-left: auto;
font-size: 0.9em;
opacity: 0.7;
white-space: nowrap;
> .time {
flex-shrink: 0;
@ -648,16 +730,12 @@ function emitUpdReaction(emoji: string, delta: number) {
}
}
> span {
margin-left: 8px;
> *:not(:last-child) {
margin-right: 8px;
}
}
}
> .renote + .article {
padding-top: 8px;
}
> .article {
display: flex;
padding: 12px 16px;
@ -724,11 +802,6 @@ function emitUpdReaction(emoji: string, delta: number) {
> .text {
overflow-wrap: break-word;
> .reply {
color: var(--accent);
margin-right: 0.5em;
}
> .rp {
margin-left: 4px;
font-style: oblique;
@ -785,11 +858,14 @@ function emitUpdReaction(emoji: string, delta: number) {
> .footer {
display: none;
overflow: clip;
position: absolute;
top: 8px;
right: 8px;
padding: 0 6px;
bottom: 0px;
right: 0px;
padding: 2px 8px;
opacity: 0.7;
background: var(--panel);
border-top-left-radius: var(--radius);
&:hover {
opacity: 1;

View File

@ -0,0 +1,286 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :class="$style.main">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<div v-if="channel && tab === 'overview'" key="overview" class="_gaps">
<div class="_panel" :class="$style.bannerContainer">
<div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : undefined }" :class="$style.banner">
<div :class="$style.bannerStatus">
<div><i class="ti ti-users ti-fw"></i><I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
<div><i class="ti ti-pencil ti-fw"></i><I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
</div>
<div v-if="channel.isSensitive" :class="$style.sensitiveIndicator">{{ i18n.ts.sensitive }}</div>
<div :class="$style.bannerFade"></div>
</div>
<div v-if="channel.description" :class="$style.description">
<Mfm :text="channel.description" :isNote="false"/>
</div>
</div>
<MkFoldableSection>
<template #header><i class="ti ti-pin ti-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedNotes }}</template>
<div v-if="channel.pinnedNotes && channel.pinnedNotes.length > 0" class="_gaps">
<MkNote v-for="note in channel.pinnedNotes" :key="note.id" class="_panel" :note="note"/>
</div>
</MkFoldableSection>
</div>
<div v-else-if="tab === 'featured'" key="featured">
<MkNotes :pagination="featuredPagination"/>
</div>
<div v-else-if="tab === 'search'" key="search">
<div class="_gaps">
<div>
<MkInput v-model="searchQuery" @enter="search()">
<template #prefix><i class="ti ti-search"></i></template>
</MkInput>
<MkButton primary rounded style="margin-top: 8px;" @click="search()">{{ i18n.ts.search }}</MkButton>
</div>
<MkNotes v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/>
</div>
</div>
</MkHorizontalSwipe>
</MkSpacer>
<template #footer>
<div :class="$style.footer">
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
<div class="_buttonsCenter">
<MkButton inline rounded primary gradate @click="goToTl()"><i class="ti ti-pencil"></i> {{ i18n.ts.postToTheChannel }}</MkButton>
</div>
</MkSpacer>
</div>
</template>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, watch, ref } from 'vue';
import * as Misskey from 'misskey-js';
import MkPostForm from '@/components/MkPostForm.vue';
import MkTimeline from '@/components/MkTimeline.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { $i, iAmModerator } from '@/account.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { deviceKind } from '@/scripts/device-kind.js';
import MkNotes from '@/components/MkNotes.vue';
import { url } from '@/config.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import { defaultStore } from '@/store.js';
import MkNote from '@/components/MkNote.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import { PageHeaderItem } from '@/types/page-header.js';
import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { miLocalStorage } from '@/local-storage.js';
import { useRouter } from '@/router/supplier.js';
const router = useRouter();
const props = defineProps<{
channelId: string;
}>();
const tab = ref('overview');
const channel = ref<Misskey.entities.Channel | null>(null);
const favorited = ref(false);
const searchQuery = ref('');
const searchPagination = ref();
const searchKey = ref('');
const featuredPagination = computed(() => ({
endpoint: 'notes/featured' as const,
limit: 10,
params: {
channelId: props.channelId,
},
}));
watch(() => props.channelId, async () => {
channel.value = await misskeyApi('channels/show', {
channelId: props.channelId,
});
favorited.value = channel.value.isFavorited ?? false;
}, { immediate: true });
function edit() {
router.push(`/channels/${channel.value?.id}/edit`);
}
function goToTl() {
router.push(`/channels/${channel.value?.id}`)
}
async function search() {
if (!channel.value) return;
const query = searchQuery.value.toString().trim();
if (query == null) return;
searchPagination.value = {
endpoint: 'notes/search',
limit: 10,
params: {
query: query,
channelId: channel.value.id,
},
};
searchKey.value = query;
}
const headerActions = computed(() => {
if (channel.value && channel.value.userId) {
const headerItems: PageHeaderItem[] = [];
headerItems.push({
icon: 'ti ti-link',
text: i18n.ts.copyUrl,
handler: async (): Promise<void> => {
if (!channel.value) {
console.warn('failed to copy channel URL. channel.value is null.');
return;
}
copyToClipboard(`${url}/channels/${channel.value.id}`);
os.success();
},
});
if (isSupportShare()) {
headerItems.push({
icon: 'ti ti-share',
text: i18n.ts.share,
handler: async (): Promise<void> => {
if (!channel.value) {
console.warn('failed to share channel. channel.value is null.');
return;
}
navigator.share({
title: channel.value.name,
text: channel.value.description ?? undefined,
url: `${url}/channels/${channel.value.id}`,
});
},
});
}
if (($i && $i.id === channel.value.userId) || iAmModerator) {
headerItems.push({
icon: 'ti ti-settings',
text: i18n.ts.edit,
handler: edit,
});
}
return headerItems.length > 0 ? headerItems : null;
} else {
return null;
}
});
const headerTabs = computed(() => [{
key: 'overview',
title: i18n.ts.overview,
icon: 'ti ti-info-circle',
}, {
key: 'featured',
title: i18n.ts.featured,
icon: 'ti ti-bolt',
}, {
key: 'search',
title: i18n.ts.search,
icon: 'ti ti-search',
}]);
definePageMetadata(() => ({
title: channel.value ? channel.value.name : i18n.ts.channel,
icon: 'ti ti-device-tv',
}));
</script>
<style lang="scss" module>
.main {
min-height: calc(100cqh - (var(--stickyTop, 0px) + var(--stickyBottom, 0px)));
}
.footer {
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
background: var(--acrylicBg);
border-top: solid 0.5px var(--divider);
}
.bannerContainer {
position: relative;
}
.subscribe {
position: absolute;
z-index: 1;
top: 16px;
left: 16px;
}
.favorite {
position: absolute;
z-index: 1;
top: 16px;
right: 16px;
}
.banner {
position: relative;
height: 200px;
background-position: center;
background-size: cover;
}
.bannerFade {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 64px;
background: linear-gradient(0deg, var(--panel), var(--X15));
}
.bannerStatus {
position: absolute;
z-index: 1;
bottom: 16px;
right: 16px;
padding: 8px 12px;
font-size: 80%;
background: rgba(0, 0, 0, 0.7);
border-radius: 6px;
color: #fff;
}
.description {
padding: 16px;
}
.sensitiveIndicator {
position: absolute;
z-index: 1;
bottom: 16px;
left: 16px;
background: rgba(0, 0, 0, 0.7);
color: var(--warn);
border-radius: 6px;
font-weight: bold;
font-size: 1em;
padding: 4px 7px;
}
</style>

View File

@ -1,26 +1,26 @@
<template>
<div class="dbiokgaf">
<MkPageHeader @click="goTop()"/>
<div v-if="timetravelTarget" class="info">
<MkInfo>{{ i18n.ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ i18n.ts.clear }}</button></MkInfo>
<div :class="$style.root">
<MkPageHeader @click="scrollToNew()" :actions="headerActions"/>
<div v-if="timetravelTarget" :class="$style.info">
<MkInfo>{{ i18n.ts.showingPastTimeline }} <button class="_textButton" @click="timetravel()">{{ i18n.ts.clear }}</button></MkInfo>
</div>
<div class="body" :class="{ reverse: inChannel }">
<div class="postform">
<div :class="[$style.body, { [$style.reverse]: inChannel }]">
<div :class="$style.postform">
<div v-if="typers.length > 0" class="typers">
<I18n :src="i18n.ts.typingUsers" text-tag="span" class="users">
<I18n :src="i18n.ts.typingUsers" text-tag="span">
<template #users>
<b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b>
<b v-for="user in typers" :key="user.id" :class="$style.user">{{ user.username }}</b>
</template>
</I18n>
<MkEllipsis/>
</div>
<XPostForm :channel="channel" :autofocus="!inChannel" />
</div>
<div ref="scroller" class="tl">
<div v-if="queue > 0" class="new" :style="{ width: width + 'px', top: inChannel ? null : top + 'px', bottom: inChannel ? bottom + 'px' : null }">
<div ref="scroller" :class="$style.tl">
<div v-if="queue > 0" :class="$style.new" :style="{ width: width + 'px', top: inChannel ? null : top + 'px', bottom: inChannel ? bottom + 'px' : null }">
<button class="_buttonPrimary" @click="scrollToNew()">{{ i18n.ts.newNoteRecived }}</button>
</div>
<XNotes ref="tlElement" class="tl" :pagination="pagination" :reversed="inChannel" @queue="queueUpdated"/>
<XNotes ref="tlElement" :pagination="pagination" :reversed="inChannel" @queue="queueUpdated"/>
</div>
</div>
</div>
@ -35,13 +35,19 @@ import XNotes from '../notes.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { useStream } from '@/stream.js';
import * as sound from '@/scripts/sound.js';
import * as os from '@/os.js';
import { scrollToBottom, scrollToTop } from '@/scripts/scroll.js';
import XPostForm from '../post-form.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkChannelFollowButton from '@/components/MkChannelFollowButton.vue';
import { definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
import { useRouter } from '@/router/supplier.js';
import { miLocalStorage } from '@/local-storage.js';
const router = useRouter();
type Props = {
tlSrc?: string;
@ -54,6 +60,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits(['note']);
const tlElement = ref();
const tab = ref('timeline');
const inChannel = computed(() => props.tlSrc == 'channel');
provide('inChannel', inChannel);
@ -63,6 +70,15 @@ watch(() => props.channelId, async () => {
channel.value = await misskeyApi('channels/show', {
channelId: props.channelId,
});
if ((channel.value.isFavorited || channel.value.isFollowing) && channel.value.lastNotedAt) {
const lastReadedAt: number = miLocalStorage.getItemAsJson(`channelLastReadedAt:${channel.value.id}`) ?? 0;
const lastNotedAt = Date.parse(channel.value.lastNotedAt);
if (lastNotedAt > lastReadedAt) {
miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.value.id}`, lastNotedAt);
}
}
}
}, { immediate: true });
@ -188,91 +204,145 @@ function scrollToNew() {
}
};
const headerActions = computed(() => {
if (channel.value && channel.value.userId) {
const headerItems: PageHeaderItem[] = [];
headerItems.push({
icon: 'ti ti-star',
text: i18n.ts.favorite,
highlighted: channel.value.isFavorited,
handler: async () => {
if (channel.value.isFavorited) {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unfavoriteConfirm,
});
if (confirm.canceled) return;
await os.apiWithDialog('channels/unfavorite', {
channelId: channel.value.id,
});
channel.value.isFavorited = false;
} else {
await os.apiWithDialog('channels/favorite', {
channelId: channel.value.id,
});
channel.value.isFavorited = true;
}
}
});
headerItems.push({
icon: channel.value.isFollowing ? 'ti ti-minus' : 'ti ti-plus',
text: channel.value.isFollowing ? i18n.ts.unfollow : i18n.ts.follow,
highlighted: channel.value.isFollowing,
handler: async () => {
if (channel.value.isFollowing) {
await os.apiWithDialog('channels/unfollow', {
channelId: channel.value.id,
});
channel.value.isFollowing = false;
} else {
await os.apiWithDialog('channels/follow', {
channelId: channel.value.id,
});
channel.value.isFollowing = true;
}
}
});
headerItems.push({
icon: 'ti ti-info-circle',
text: i18n.ts.about,
handler: () => router.push(`/channels/${channel.value?.id}/about`)
});
return headerItems.length > 0 ? headerItems : null;
} else {
return null;
}
});
definePageMetadata(() => metadata.value);
provideReactiveMetadata(metadata);
</script>
<style lang="scss" scoped>
.dbiokgaf {
<style lang="scss" module>
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: clip;
contain: strict;
background: var(--panel);
}
> .info {
.info {
padding: 16px 16px 0 16px;
}
.body {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.postform {
padding: 16px 16px 0 16px;
}
> .body {
flex: 1;
display: flex;
flex-direction: column;
overflow: auto;
overflow-y: scroll;
> .postform {
padding: 16px 16px 0 16px;
}
&.reverse {
flex-direction: column-reverse;
> .postform {
padding: 0 16px 16px 16px;
}
}
> .tl {
position: relative;
padding: 16px 0;
flex: 1;
min-width: 0;
overflow: auto;
> .new {
position: fixed;
z-index: 1000;
pointer-events: none;
> button {
display: block;
margin: 16px auto;
padding: 8px 16px;
border-radius: 32px;
pointer-events: auto;
}
}
&.reverse {
flex-direction: column-reverse;
.postform {
padding: 0 16px 16px 16px;
}
}
}
.bottom {
padding: 0 16px 16px 16px;
position: relative;
.tl {
position: relative;
padding: 16px 0;
flex: 1;
min-width: 0;
overflow: auto;
}
> .typers {
position: absolute;
bottom: 100%;
padding: 0 8px 0 8px;
font-size: 0.9em;
background: var(--panel);
border-radius: 0 8px 0 0;
color: var(--fgTransparentWeak);
.new {
position: fixed;
z-index: 1000;
pointer-events: none;
> .users {
> .user + .user:before {
content: ", ";
font-weight: normal;
}
> button {
display: block;
margin: 16px auto;
padding: 8px 16px;
border-radius: 32px;
pointer-events: auto;
}
}
> .user:last-of-type:after {
content: " ";
}
}
}
.bottom {
padding: 0 16px 16px 16px;
position: relative;
}
.typers {
position: absolute;
bottom: 100%;
padding: 0 8px 0 8px;
font-size: 0.9em;
background: var(--panel);
border-radius: 0 8px 0 0;
color: var(--fgTransparentWeak);
.user + .user:before {
content: ", ";
font-weight: normal;
}
.user:last-of-type:after {
content: " ";
}
}
</style>