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> <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> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -21,6 +21,7 @@ const props = defineProps<{
renote?: Misskey.entities.Note | null; renote?: Misskey.entities.Note | null;
files?: Misskey.entities.DriveFile[]; files?: Misskey.entities.DriveFile[];
poll?: Misskey.entities.Note['poll'] | PollEditorModelValue | null; poll?: Misskey.entities.Note['poll'] | PollEditorModelValue | null;
small?: boolean | null;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{

View File

@ -36,6 +36,7 @@ import { deviceKind } from '@/scripts/device-kind.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
src?: HTMLElement; src?: HTMLElement;
anchor?: { x: string; y: string; }; anchor?: { x: string; y: string; };
all?: boolean | null;
}>(), { }>(), {
anchor: () => ({ x: 'right', y: 'center' }), anchor: () => ({ x: 'right', y: 'center' }),
}); });
@ -52,7 +53,7 @@ const modal = shallowRef<InstanceType<typeof MkModal>>();
const menu = defaultStore.state.menu; 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', type: def.to ? 'link' : 'button',
text: def.title, text: def.title,
icon: def.icon, 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> <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> </div>
<div v-if="followedChannels" class="container"> <div v-if="favoriteChannels" 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 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"> <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> <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="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>
</div> </div>
</div> </div>
<div v-if="lists" class="container"> <div v-if="lists" class="container">
@ -43,7 +37,7 @@
</div> </div>
</div> </div>
<div v-if="antennas" class="container"> <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"> <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> <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> </div>
@ -55,10 +49,14 @@
<button class="_button menu" @click="showMenu"> <button class="_button menu" @click="showMenu">
<i class="ti ti-menu-2 icon"></i> <i class="ti ti-menu-2 icon"></i>
</button> </button>
</div> </div>
<div class="right"> <div class="right">
<button v-tooltip="i18n.ts.search" class="_button item search" @click="search"> <MkA v-if="$i.isAdmin || $i.isModerator" v-tooltip="i18n.ts.controlPanel" class="item" to="/admin">
<i class="ti ti-search icon"></i> <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> </button>
<MkA v-tooltip="i18n.ts.settings" class="item" to="/settings"><i class="ti ti-settings icon"></i></MkA> <MkA v-tooltip="i18n.ts.settings" class="item" to="/settings"><i class="ti ti-settings icon"></i></MkA>
</div> </div>
@ -101,11 +99,9 @@ import { provide, ref, shallowRef, toRefs, computed, onMounted, defineAsyncCompo
import { instanceName, url } from '@/config.js'; import { instanceName, url } from '@/config.js';
import XWidgets from './chat/widgets.vue'; import XWidgets from './chat/widgets.vue';
import XCommon from './_common_/common.vue'; import XCommon from './_common_/common.vue';
import XHeaderClock from './chat/header-clock.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { search } from '@/scripts/search.js';
import makeList from '@/scripts/new-list.js'; import makeList from '@/scripts/new-list.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js'; import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.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 { defaultRoutes, page } from '@/router/definition.js';
import { useRouterFactory } from '@/router/supplier.js'; import { useRouterFactory } from '@/router/supplier.js';
const XDrawerMenu = defineAsyncComponent(() => import('@/ui/_common_/navbar-for-mobile.vue')); const XDrawerMenu = defineAsyncComponent(() => import('@/ui/_common_/navbar-for-mobile.vue'));
const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue'));
const XLaunchPad = defineAsyncComponent(() => import('@/components/MkLaunchPad.vue'));
const routes = [ const routes = [
{ {
@ -129,6 +127,9 @@ const routes = [
globalCacheKey: 'index', globalCacheKey: 'index',
}, { }, {
...defaultRoutes.find((route) => route.path == '/channels/new'), ...defaultRoutes.find((route) => route.path == '/channels/new'),
}, {
path: '/channels/:channelId/about',
component: page(() => import('./chat/pages/channels.about.vue')),
}, { }, {
path: '/channels/:channelId', path: '/channels/:channelId',
props: { tlSrc: 'channel' }, props: { tlSrc: 'channel' },
@ -192,8 +193,7 @@ const menu = computed(() => [{
const lists = ref([]); const lists = ref([]);
const antennas = ref([]); const antennas = ref([]);
const followedChannels = ref([]); const favoriteChannels = ref([]);
const featuredChannels = ref([]);
function getSidebarContent() { function getSidebarContent() {
misskeyApi('users/lists/list').then(newLists => { misskeyApi('users/lists/list').then(newLists => {
@ -204,22 +204,11 @@ function getSidebarContent() {
antennas.value = newAntennas; antennas.value = newAntennas;
}); });
misskeyApi('channels/followed', { limit: 20 }).then(channels => { misskeyApi('channels/my-favorites').then(channels => {
followedChannels.value = channels; favoriteChannels.value = channels;
});
// TODO: pagination
misskeyApi('channels/featured', { limit: 20 }).then(channels => {
featuredChannels.value = channels;
}); });
} }
function addChannel() {
chatRouter.push('/channels/new');
}
function addAntenna() {
chatRouter.push('/my/antennas/create');
}
async function addList() { async function addList() {
const success = await makeList(); const success = await makeList();
if (success) { if (success) {
@ -235,6 +224,13 @@ function hideMenu() {
drawerMenuShowing.value = false; drawerMenuShowing.value = false;
} }
function more(ev: MouseEvent) {
os.popup(XLaunchPad, {
src: ev.currentTarget ?? ev.target,
all: true,
}, {}, 'closed');
}
function post() { function post() {
os.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> <template>
<header class="dehvdgxo"> <header :class="$style.root">
<MkA v-user-preview="note.user.id" class="name" :to="userPage(note.user)"> <div :class="$style.left">
<MkA v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)">
<MkUserName :user="note.user"/> <MkUserName :user="note.user"/>
</MkA> </MkA>
<span v-if="note.user.isBot" class="is-bot">bot</span> <span v-if="note.user.isBot" :class="$style.is-bot">bot</span>
<span class="username"><MkAcct :user="note.user"/></span> <span :class="$style.username"><MkAcct :user="note.user"/></span>
<div class="info"> <div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
<MkA class="created-at" :to="notePage(note)"> <img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
<MkTime :time="note.createdAt"/> </div>
</MkA> </div>
<span v-if="note.visibility !== 'public'" class="visibility"> <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-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 === 'followers'" class="ti ti-lock"></i>
<i v-else-if="note.visibility === 'specified'" class="ti ti-mail"></i> <i v-else-if="note.visibility === 'specified'" class="ti ti-mail"></i>
</span> </span>
<span v-if="note.localOnly" class="localOnly"><i class="ti ti-rocket-off"></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> <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> </div>
</header> </header>
</template> </template>
@ -45,14 +52,21 @@ export default defineComponent({
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.dehvdgxo { .root {
display: flex; display: flex;
width: 100%;
align-items: baseline; align-items: baseline;
white-space: nowrap; white-space: nowrap;
font-size: 0.9em; font-size: 0.9em;
> .name { .left {
display: flex;
margin-right: auto;
}
}
.name {
display: block; display: block;
margin: 0 .5em 0 0; margin: 0 .5em 0 0;
padding: 0; padding: 0;
@ -67,7 +81,7 @@ export default defineComponent({
} }
} }
> .is-bot { .isBot {
flex-shrink: 0; flex-shrink: 0;
align-self: center; align-self: center;
margin: 0 .5em 0 0; margin: 0 .5em 0 0;
@ -77,19 +91,30 @@ export default defineComponent({
border-radius: 3px; border-radius: 3px;
} }
> .username { .username {
margin: 0 .5em 0 0; margin: 0 .5em 0 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
> .info { .badgeRoles {
font-size: 0.9em; margin: 0 .5em 0 0;
}
.badgeRole {
height: 1.3em;
vertical-align: -20%;
& + .badgeRole {
margin-left: 0.2em;
}
}
.info {
opacity: 0.7; opacity: 0.7;
> span { > *:not(:last-child) {
margin-left: 8px; margin-right: 8px;
}
} }
} }
</style> </style>

View File

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

View File

@ -5,14 +5,35 @@
ref="rootEl" ref="rootEl"
v-hotkey="keymap" v-hotkey="keymap"
class="vfzoeqcg" class="vfzoeqcg"
:class="{ renote: isRenote, operating: operating }" :class="{ collapsed: renoteCollapsed, operating: operating }"
:tabindex="!isDeleted ? '-1' : undefined" :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="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._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="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"/> <MkAvatar class="avatar" :user="note.user"/>
<i class="ti ti-repeat"></i> <i class="ti ti-repeat"></i>
<I18n :src="i18n.ts.renotedBy" tag="span"> <I18n :src="i18n.ts.renotedBy" tag="span">
@ -23,10 +44,6 @@
</template> </template>
</I18n> </I18n>
<div class="info"> <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]"> <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-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 === 'followers'" class="ti ti-lock"></i>
@ -34,14 +51,13 @@
</span> </span>
<span v-if="note.localOnly" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></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> <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> </div>
<div v-if="renoteCollapsed" class="collapsed-renote"> <article v-if="!renoteCollapsed" class="article" @contextmenu.stop="onContextmenu">
<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> -->
<MkAvatar class="avatar" :user="appearNote.user"/> <MkAvatar class="avatar" :user="appearNote.user"/>
<div class="main"> <div class="main">
<XNoteHeader class="header" :note="appearNote" :mini="true"/> <XNoteHeader class="header" :note="appearNote" :mini="true"/>
@ -49,12 +65,11 @@
<div class="body" style="container-type: inline-size;"> <div class="body" style="container-type: inline-size;">
<p v-if="appearNote.cw != null" class="cw"> <p v-if="appearNote.cw != null" class="cw">
<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/> <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> </p>
<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed }"> <div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed }">
<div class="text"> <div class="text">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts.private }})</span> <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"/> <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis"/>
<div v-if="translating || translation" class="translation"> <div v-if="translating || translation" class="translation">
<MkLoading v-if="translating" mini/> <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> <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> </div>
<MkReactionsViewer :note="appearNote"/> <MkReactionsViewer :note="appearNote"/>
<footer class="footer _panel"> <footer class="footer">
<button v-tooltip="i18n.ts.reply" class="button _button" @click="reply()"> <button v-tooltip="i18n.ts.reply" class="button _button" @click="reply()">
<i class="ti ti-arrow-back-up"></i> <i class="ti ti-arrow-back-up"></i>
<p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p> <p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p>
@ -246,6 +261,12 @@ const renoteCollapsed = ref(
); );
const operating = ref(false); const operating = ref(false);
onMounted(() => {
if (appearNote.value.channel && rootEl.value && !inChannel.value) {
rootEl.value.style.setProperty('--channelColor', appearNote.value.channel.color);
}
});
/* Overload FunctionLint /* 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: true): boolean;
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute'; 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 { &:hover, &.operating {
> .article > .main > .footer { > .article > .main > .footer {
display: block; display: block;
opacity: 1;
}
&::before {
opacity: 0;
} }
} }
&.renote { &::before {
background: rgba(128, 255, 0, 0.05); 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 { &::after {
background: rgba(255, 128, 0, 0.05); content: "";
position: absolute;
top: 0; left: 0;
width: 5px; height: 100%;
z-index: -1;
background: var(--channelColor);
} }
> .info { > .info {
@ -596,19 +634,58 @@ function emitUpdReaction(emoji: string, delta: number) {
padding-top: 8px; padding-top: 8px;
} }
> .reply-to { > .collapsed-renote {
opacity: 0.7; display: flex;
padding-bottom: 0; align-items: center;
} padding-bottom: 8px;
overflow: hidden;
> .renote, > .collapsed-renote { 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; display: flex;
align-items: center; align-items: center;
padding: 12px 16px 4px 16px;
line-height: 28px; line-height: 28px;
white-space: pre; white-space: pre;
color: var(--renote);
font-size: 0.9em;
> .avatar { > .avatar {
flex-shrink: 0; flex-shrink: 0;
@ -620,7 +697,7 @@ function emitUpdReaction(emoji: string, delta: number) {
} }
> i { > i {
margin-right: 4px; margin-right: 0.5em;
} }
> span { > span {
@ -633,11 +710,16 @@ function emitUpdReaction(emoji: string, delta: number) {
font-weight: bold; font-weight: bold;
} }
} }
}
> .renote-header, > .collapsed-renote {
color: var(--renote);
> .info { > .info {
margin-left: 8px; margin-left: auto;
font-size: 0.9em; font-size: 0.9em;
opacity: 0.7; opacity: 0.7;
white-space: nowrap;
> .time { > .time {
flex-shrink: 0; flex-shrink: 0;
@ -648,16 +730,12 @@ function emitUpdReaction(emoji: string, delta: number) {
} }
} }
> span { > *:not(:last-child) {
margin-left: 8px; margin-right: 8px;
} }
} }
} }
> .renote + .article {
padding-top: 8px;
}
> .article { > .article {
display: flex; display: flex;
padding: 12px 16px; padding: 12px 16px;
@ -724,11 +802,6 @@ function emitUpdReaction(emoji: string, delta: number) {
> .text { > .text {
overflow-wrap: break-word; overflow-wrap: break-word;
> .reply {
color: var(--accent);
margin-right: 0.5em;
}
> .rp { > .rp {
margin-left: 4px; margin-left: 4px;
font-style: oblique; font-style: oblique;
@ -785,11 +858,14 @@ function emitUpdReaction(emoji: string, delta: number) {
> .footer { > .footer {
display: none; display: none;
overflow: clip;
position: absolute; position: absolute;
top: 8px; bottom: 0px;
right: 8px; right: 0px;
padding: 0 6px; padding: 2px 8px;
opacity: 0.7; opacity: 0.7;
background: var(--panel);
border-top-left-radius: var(--radius);
&:hover { &:hover {
opacity: 1; 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> <template>
<div class="dbiokgaf"> <div :class="$style.root">
<MkPageHeader @click="goTop()"/> <MkPageHeader @click="scrollToNew()" :actions="headerActions"/>
<div v-if="timetravelTarget" class="info"> <div v-if="timetravelTarget" :class="$style.info">
<MkInfo>{{ i18n.ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ i18n.ts.clear }}</button></MkInfo> <MkInfo>{{ i18n.ts.showingPastTimeline }} <button class="_textButton" @click="timetravel()">{{ i18n.ts.clear }}</button></MkInfo>
</div> </div>
<div class="body" :class="{ reverse: inChannel }"> <div :class="[$style.body, { [$style.reverse]: inChannel }]">
<div class="postform"> <div :class="$style.postform">
<div v-if="typers.length > 0" class="typers"> <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> <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> </template>
</I18n> </I18n>
<MkEllipsis/> <MkEllipsis/>
</div> </div>
<XPostForm :channel="channel" :autofocus="!inChannel" /> <XPostForm :channel="channel" :autofocus="!inChannel" />
</div> </div>
<div ref="scroller" class="tl"> <div ref="scroller" :class="$style.tl">
<div v-if="queue > 0" class="new" :style="{ width: width + 'px', top: inChannel ? null : top + 'px', bottom: inChannel ? bottom + 'px' : null }"> <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> <button class="_buttonPrimary" @click="scrollToNew()">{{ i18n.ts.newNoteRecived }}</button>
</div> </div>
<XNotes ref="tlElement" class="tl" :pagination="pagination" :reversed="inChannel" @queue="queueUpdated"/> <XNotes ref="tlElement" :pagination="pagination" :reversed="inChannel" @queue="queueUpdated"/>
</div> </div>
</div> </div>
</div> </div>
@ -35,13 +35,19 @@ import XNotes from '../notes.vue';
import { misskeyApi } from '@/scripts/misskey-api.js'; import { misskeyApi } from '@/scripts/misskey-api.js';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
import * as sound from '@/scripts/sound.js'; import * as sound from '@/scripts/sound.js';
import * as os from '@/os.js';
import { scrollToBottom, scrollToTop } from '@/scripts/scroll.js'; import { scrollToBottom, scrollToTop } from '@/scripts/scroll.js';
import XPostForm from '../post-form.vue'; import XPostForm from '../post-form.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import MkChannelFollowButton from '@/components/MkChannelFollowButton.vue';
import { definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { useRouter } from '@/router/supplier.js';
import { miLocalStorage } from '@/local-storage.js';
const router = useRouter();
type Props = { type Props = {
tlSrc?: string; tlSrc?: string;
@ -54,6 +60,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits(['note']); const emit = defineEmits(['note']);
const tlElement = ref(); const tlElement = ref();
const tab = ref('timeline');
const inChannel = computed(() => props.tlSrc == 'channel'); const inChannel = computed(() => props.tlSrc == 'channel');
provide('inChannel', inChannel); provide('inChannel', inChannel);
@ -63,6 +70,15 @@ watch(() => props.channelId, async () => {
channel.value = await misskeyApi('channels/show', { channel.value = await misskeyApi('channels/show', {
channelId: props.channelId, 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 }); }, { immediate: true });
@ -188,50 +204,111 @@ 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); definePageMetadata(() => metadata.value);
provideReactiveMetadata(metadata); provideReactiveMetadata(metadata);
</script> </script>
<style lang="scss" scoped> <style lang="scss" module>
.dbiokgaf { .root {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
overflow: clip; overflow: clip;
contain: strict; contain: strict;
background: var(--panel); background: var(--panel);
}
> .info { .info {
padding: 16px 16px 0 16px; padding: 16px 16px 0 16px;
} }
> .body { .body {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: auto; overflow: hidden;
overflow-y: scroll;
> .postform { .postform {
padding: 16px 16px 0 16px; padding: 16px 16px 0 16px;
} }
&.reverse { &.reverse {
flex-direction: column-reverse; flex-direction: column-reverse;
> .postform { .postform {
padding: 0 16px 16px 16px; padding: 0 16px 16px 16px;
} }
} }
}
> .tl { .tl {
position: relative; position: relative;
padding: 16px 0; padding: 16px 0;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
overflow: auto; overflow: auto;
}
> .new { .new {
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
pointer-events: none; pointer-events: none;
@ -244,14 +321,13 @@ provideReactiveMetadata(metadata);
pointer-events: auto; pointer-events: auto;
} }
} }
}
}
.bottom { .bottom {
padding: 0 16px 16px 16px; padding: 0 16px 16px 16px;
position: relative; position: relative;
}
> .typers { .typers {
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
padding: 0 8px 0 8px; padding: 0 8px 0 8px;
@ -260,19 +336,13 @@ provideReactiveMetadata(metadata);
border-radius: 0 8px 0 0; border-radius: 0 8px 0 0;
color: var(--fgTransparentWeak); color: var(--fgTransparentWeak);
> .users { .user + .user:before {
> .user + .user:before {
content: ", "; content: ", ";
font-weight: normal; font-weight: normal;
} }
> .user:last-of-type:after { .user:last-of-type:after {
content: " "; content: " ";
} }
} }
}
}
}
</style> </style>