Merge branch 'develop' into sw-notification-action

This commit is contained in:
tamaina 2021-02-15 06:22:24 +09:00
commit 5cb24e5f07
22 changed files with 3463 additions and 44 deletions

View File

@ -1,8 +1,8 @@
<template> <template>
<span class="eiwwqkts" :class="{ cat }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick"> <span class="eiwwqkts _noSelect" :class="{ cat }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick">
<img class="inner" :src="url" decoding="async"/> <img class="inner" :src="url" decoding="async"/>
</span> </span>
<MkA class="eiwwqkts" :class="{ cat }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id"> <MkA class="eiwwqkts _noSelect" :class="{ cat }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id">
<img class="inner" :src="url" decoding="async"/> <img class="inner" :src="url" decoding="async"/>
</MkA> </MkA>
</template> </template>

View File

@ -8,7 +8,7 @@
<MkError v-if="error" @retry="init()"/> <MkError v-if="error" @retry="init()"/>
<div v-show="more && reversed" style="margin-bottom: var(--margin);"> <div v-show="more && reversed" style="margin-bottom: var(--margin);">
<button class="_loadMore" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> <button class="_buttonPrimary" @click="fetchMoreFeature" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template> <template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template> <template v-if="moreFetching"><MkLoading inline/></template>
</button> </button>
@ -19,7 +19,7 @@
</XList> </XList>
<div v-show="more && !reversed" style="margin-top: var(--margin);"> <div v-show="more && !reversed" style="margin-top: var(--margin);">
<button class="_loadMore" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> <button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template> <template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template> <template v-if="moreFetching"><MkLoading inline/></template>
</button> </button>

View File

@ -5,7 +5,7 @@
<XNotification v-else :notification="notification" :with-time="true" :full="true" class="_panel notification" :key="notification.id"/> <XNotification v-else :notification="notification" :with-time="true" :full="true" class="_panel notification" :key="notification.id"/>
</XList> </XList>
<button class="_loadMore" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> <button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template> <template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template> <template v-if="moreFetching"><MkLoading inline/></template>
</button> </button>

View File

@ -55,6 +55,14 @@ import { sidebarDef } from '@/sidebar';
import { getAccounts, addAccount, login } from '@/account'; import { getAccounts, addAccount, login } from '@/account';
export default defineComponent({ export default defineComponent({
props: {
defaultHidden: {
type: Boolean,
required: false,
default: false,
}
},
data() { data() {
return { return {
host: host, host: host,
@ -63,7 +71,7 @@ export default defineComponent({
connection: null, connection: null,
menuDef: sidebarDef, menuDef: sidebarDef,
iconOnly: false, iconOnly: false,
hidden: false, hidden: this.defaultHidden,
faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faProjectDiagram faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faProjectDiagram
}; };
}, },
@ -112,7 +120,9 @@ export default defineComponent({
methods: { methods: {
calcViewState() { calcViewState() {
this.iconOnly = (window.innerWidth <= 1279) || (this.$store.state.sidebarDisplay === 'icon'); this.iconOnly = (window.innerWidth <= 1279) || (this.$store.state.sidebarDisplay === 'icon');
if (!this.defaultHidden) {
this.hidden = (window.innerWidth <= 650); this.hidden = (window.innerWidth <= 650);
}
}, },
show() { show() {

View File

@ -3,12 +3,24 @@ import { getScrollContainer, getScrollPosition } from '@/scripts/scroll';
export default { export default {
mounted(src, binding, vn) { mounted(src, binding, vn) {
const ro = new ResizeObserver((entries, observer) => { if (binding.value === false) return;
const pos = getScrollPosition(src);
const container = getScrollContainer(src); let isBottom = true;
const container = getScrollContainer(src)!;
container.addEventListener('scroll', () => {
const pos = getScrollPosition(container);
const viewHeight = container.clientHeight; const viewHeight = container.clientHeight;
const height = container.scrollHeight; const height = container.scrollHeight;
if (pos + viewHeight > height - 32) { isBottom = (pos + viewHeight > height - 32);
console.log(isBottom);
}, { passive: true });
container.scrollTop = container.scrollHeight;
const ro = new ResizeObserver((entries, observer) => {
console.log(isBottom);
if (isBottom) {
const height = container.scrollHeight;
container.scrollTop = height; container.scrollTop = height;
} }
}); });
@ -20,6 +32,6 @@ export default {
}, },
unmounted(src, binding, vn) { unmounted(src, binding, vn) {
src._ro_.unobserve(src); if (src._ro_) src._ro_.unobserve(src);
} }
} as Directive; } as Directive;

View File

@ -214,6 +214,7 @@ const app = createApp(await (
!$i ? import('@/ui/visitor.vue') : !$i ? import('@/ui/visitor.vue') :
ui === 'deck' ? import('@/ui/deck.vue') : ui === 'deck' ? import('@/ui/deck.vue') :
ui === 'desktop' ? import('@/ui/desktop.vue') : ui === 'desktop' ? import('@/ui/desktop.vue') :
ui === 'chat' ? import('@/ui/chat/index.vue') :
import('@/ui/default.vue') import('@/ui/default.vue')
).then(x => x.default)); ).then(x => x.default));

View File

@ -1,9 +1,11 @@
import { markRaw } from 'vue'; import { markRaw } from 'vue';
import * as os from '@/os'; import * as os from '@/os';
import { onScrollTop, isTopVisible } from './scroll'; import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from './scroll';
const SECOND_FETCH_LIMIT = 30; const SECOND_FETCH_LIMIT = 30;
// reversed: items 配列の中身を逆順にする(新しい方が最後)
export default (opts) => ({ export default (opts) => ({
emits: ['queue'], emits: ['queue'],
@ -122,10 +124,41 @@ export default (opts) => ({
limit: SECOND_FETCH_LIMIT + 1, limit: SECOND_FETCH_LIMIT + 1,
...(this.pagination.offsetMode ? { ...(this.pagination.offsetMode ? {
offset: this.offset, offset: this.offset,
} : this.pagination.reversed ? {
sinceId: this.items[0].id,
} : { } : {
untilId: this.items[this.items.length - 1].id, untilId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
}),
}).then(items => {
for (const item of items) {
markRaw(item);
}
if (items.length > SECOND_FETCH_LIMIT) {
items.pop();
this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
this.more = true;
} else {
this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
this.more = false;
}
this.offset += items.length;
this.moreFetching = false;
}, e => {
this.moreFetching = false;
});
},
async fetchMoreFeature() {
if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return;
this.moreFetching = true;
let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params;
if (params && params.then) params = await params;
const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
await os.api(endpoint, {
...params,
limit: SECOND_FETCH_LIMIT + 1,
...(this.pagination.offsetMode ? {
offset: this.offset,
} : {
sinceId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
}), }),
}).then(items => { }).then(items => {
for (const item of items) { for (const item of items) {
@ -147,6 +180,24 @@ export default (opts) => ({
}, },
prepend(item) { prepend(item) {
if (this.pagination.reversed) {
const container = getScrollContainer(this.$el);
const pos = getScrollPosition(this.$el);
const viewHeight = container.clientHeight;
const height = container.scrollHeight;
const isBottom = (pos + viewHeight > height - 32);
if (isBottom) {
// オーバーフローしたら古いアイテムは捨てる
if (this.items.length >= opts.displayLimit) {
this.items = this.items.slice(-opts.displayLimit);
this.more = true;
}
} else {
}
this.items.push(item);
// TODO
} else {
const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el)); const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el));
if (isTop) { if (isTop) {
@ -167,6 +218,7 @@ export default (opts) => ({
this.queue = []; this.queue = [];
}); });
} }
}
}, },
append(item) { append(item) {

View File

@ -54,6 +54,14 @@ export function scroll(el: Element, top: number) {
} }
} }
export function scrollToTop(el: Element) {
scroll(el, 0);
}
export function scrollToBottom(el: Element) {
scroll(el, 99999); // TODO: ちゃんと計算する
}
export function isBottom(el: Element, asobi = 0) { export function isBottom(el: Element, asobi = 0) {
const container = getScrollContainer(el); const container = getScrollContainer(el);
const current = container const current = container

View File

@ -142,6 +142,12 @@ export const sidebarDef = {
localStorage.setItem('ui', 'deck'); localStorage.setItem('ui', 'deck');
unisonReload(); unisonReload();
} }
}, {
text: 'Chat (β)',
action: () => {
localStorage.setItem('ui', 'chat');
location.reload();
}
}, { }, {
text: i18n.locale.desktop + ' (β)', text: i18n.locale.desktop + ' (β)',
action: () => { action: () => {

View File

@ -308,13 +308,6 @@ hr {
box-shadow: none; box-shadow: none;
} }
._loadMore {
@extend ._panel;
@extend ._button;
width: 100%;
padding: 12px 0;
}
._borderButton { ._borderButton {
@extend ._button; @extend ._button;
display: block; display: block;

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="fdidabkb" :style="`--height:${height};`"> <div class="fdidabkb" :class="{ center }" :style="`--height:${height};`">
<transition :name="$store.state.animation ? 'header' : ''" mode="out-in" appear> <transition :name="$store.state.animation ? 'header' : ''" mode="out-in" appear>
<button class="_button back" v-if="withBack && canBack" @click.stop="back()"><Fa :icon="faChevronLeft"/></button> <button class="_button back" v-if="withBack && canBack" @click.stop="back()"><Fa :icon="faChevronLeft"/></button>
</transition> </transition>
@ -31,6 +31,11 @@ export default defineComponent({
required: false, required: false,
default: true, default: true,
}, },
center: {
type: Boolean,
required: false,
default: true,
},
}, },
data() { data() {
@ -67,7 +72,9 @@ export default defineComponent({
<style lang="scss" scoped> <style lang="scss" scoped>
.fdidabkb { .fdidabkb {
&.center {
text-align: center; text-align: center;
}
> .back { > .back {
height: var(--height); height: var(--height);
@ -111,8 +118,13 @@ export default defineComponent({
right: 0; right: 0;
} }
&.center {
> .titleContainer { > .titleContainer {
margin: 0 auto; margin: 0 auto;
}
}
> .titleContainer {
overflow: auto; overflow: auto;
white-space: nowrap; white-space: nowrap;

View File

@ -0,0 +1,154 @@
<script lang="ts">
import { defineComponent, h, TransitionGroup } from 'vue';
import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
export default defineComponent({
props: {
items: {
type: Array,
required: true,
},
direction: {
type: String,
required: false,
default: 'down'
},
reversed: {
type: Boolean,
required: false,
default: false
}
},
methods: {
focus() {
this.$slots.default[0].elm.focus();
}
},
render() {
const getDateText = (time: string) => {
const date = new Date(time).getDate();
const month = new Date(time).getMonth() + 1;
return this.$t('monthAndDay', {
month: month.toString(),
day: date.toString()
});
}
return h(!this.reversed ? TransitionGroup : 'div', !this.reversed ? {
class: 'hmjzthxl',
name: 'list',
tag: 'div',
'data-direction': this.direction,
'data-reversed': this.reversed ? 'true' : 'false',
} : {
class: 'hmjzthxl',
}, this.items.map((item, i) => {
const el = this.$slots.default({
item: item
})[0];
if (el.key == null && item.id) el.key = item.id;
if (
i != this.items.length - 1 &&
new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate() &&
!item._prId_ &&
!this.items[i + 1]._prId_ &&
!item._featuredId_ &&
!this.items[i + 1]._featuredId_
) {
const separator = h('div', {
class: 'separator',
key: item.id + ':separator',
}, h('p', {
class: 'date'
}, [
h('span', [
h(FontAwesomeIcon, {
class: 'icon',
icon: faAngleUp,
}),
getDateText(item.createdAt)
]),
h('span', [
getDateText(this.items[i + 1].createdAt),
h(FontAwesomeIcon, {
class: 'icon',
icon: faAngleDown,
})
])
]));
return [el, separator];
} else {
return el;
}
}));
},
});
</script>
<style lang="scss">
.hmjzthxl {
> .list-move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
> .list-enter-active {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
&[data-direction="up"] {
> .list-enter-from {
opacity: 0;
transform: translateY(64px);
}
}
&[data-direction="down"] {
> .list-enter-from {
opacity: 0;
transform: translateY(-64px);
}
}
}
</style>
<style lang="scss">
.hmjzthxl {
> .separator {
text-align: center;
> .date {
display: inline-block;
position: relative;
margin: 0;
padding: 0 16px;
line-height: 32px;
text-align: center;
font-size: 12px;
color: var(--dateLabelFg);
> span {
&:first-child {
margin-right: 8px;
> .icon {
margin-right: 8px;
}
}
&:last-child {
margin-left: 8px;
> .icon {
margin-left: 8px;
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,400 @@
<template>
<div class="mk-app" @contextmenu.self.prevent="onContextmenu">
<XSidebar ref="menu" class="menu" :default-hidden="true"/>
<div class="nav">
<header class="header">
<div class="left">
<button class="_button account" @click="openAccountMenu">
<MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/>
</button>
</div>
<div class="right">
<MkA class="item" to="/my/notifications"><Fa :icon="faBell"/><i v-if="$i.hasUnreadNotification"><Fa :icon="faCircle"/></i></MkA>
</div>
</header>
<div class="body">
<div class="container">
<div class="header">{{ $ts.timeline }}</div>
<div class="body">
<MkA to="/timeline/home" class="item" :class="{ active: tl === 'home' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.home }}</MkA>
<MkA to="/timeline/local" class="item" :class="{ active: tl === 'local' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.local }}</MkA>
<MkA to="/timeline/social" class="item" :class="{ active: tl === 'social' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.social }}</MkA>
<MkA to="/timeline/global" class="item" :class="{ active: tl === 'global' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.global }}</MkA>
</div>
</div>
<div class="container" v-if="lists">
<div class="header">{{ $ts.lists }}</div>
<div class="body">
<MkA v-for="list in lists" :key="list.id" :to="`/my/list/${ list.id }`" class="item" :class="{ active: tl === `list:${ list.id }` }"><Fa :icon="faListUl" class="icon"/>{{ list.name }}</MkA>
</div>
</div>
<div class="container" v-if="antennas">
<div class="header">{{ $ts.antennas }}</div>
<div class="body">
<MkA v-for="antenna in antennas" :key="antenna.id" :to="`/my/antenna/${ antenna.id }`" class="item" :class="{ active: tl === `antenna:${ antenna.id }` }"><Fa :icon="faSatellite" class="icon"/>{{ antenna.name }}</MkA>
</div>
</div>
<div class="container" v-if="followedChannels">
<div class="header">{{ $ts.channel }}</div>
<div class="body">
<MkA v-for="channel in followedChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }`, read: !channel.hasUnreadNote }"><Fa :icon="faSatelliteDish" class="icon"/>{{ channel.name }}</MkA>
</div>
</div>
<div class="container" v-if="featuredChannels">
<div class="header">{{ $ts.channel }}</div>
<div class="body">
<MkA v-for="channel in featuredChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }` }"><Fa :icon="faSatelliteDish" class="icon"/>{{ channel.name }}</MkA>
</div>
</div>
</div>
<footer class="footer">
<div class="left">
<button class="_button menu" @click="showMenu">
<Fa :icon="faBars"/>
</button>
</div>
<div class="right">
<MkA class="item" to="/settings"><Fa :icon="faCog"/></MkA>
</div>
</footer>
</div>
<main class="main" @contextmenu.stop="onContextmenu">
<header class="header" ref="header" @click="onHeaderClick">
<div v-if="tl === 'home'">
<Fa :icon="faHome" class="icon"/>
<div class="title">{{ $ts._timelines.home }}</div>
</div>
<div v-else-if="tl === 'local'">
<Fa :icon="faShareAlt" class="icon"/>
<div class="title">{{ $ts._timelines.local }}</div>
</div>
<div v-else-if="tl === 'social'">
<Fa :icon="faShareAlt" class="icon"/>
<div class="title">{{ $ts._timelines.social }}</div>
</div>
<div v-else-if="tl === 'global'">
<Fa :icon="faShareAlt" class="icon"/>
<div class="title">{{ $ts._timelines.global }}</div>
</div>
</header>
<div class="body">
<XTimeline v-if="tl.startsWith('channel:')" src="channel" :key="tl" :channel="tl.replace('channel:', '')"/>
<XTimeline v-else :src="tl" :key="tl"/>
</div>
<footer class="footer">
<XPostForm v-if="tl.startsWith('channel:')" :key="tl" :channel="tl.replace('channel:', '')"/>
<XPostForm v-else/>
</footer>
</main>
<XSide class="side" ref="side"/>
<XCommon/>
</div>
</template>
<script lang="ts">
import { defineComponent, defineAsyncComponent } from 'vue';
import { faLayerGroup, faBars, faHome, faCircle, faWindowMaximize, faColumns, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog } from '@fortawesome/free-solid-svg-icons';
import { faBell } from '@fortawesome/free-regular-svg-icons';
import { instanceName } from '@/config';
import XSidebar from '@/components/sidebar.vue';
import XCommon from '../_common_/common.vue';
import XSide from './side.vue';
import XTimeline from './timeline.vue';
import XPostForm from './post-form.vue';
import * as os from '@/os';
import { sidebarDef } from '@/sidebar';
export default defineComponent({
components: {
XCommon,
XSidebar,
XSide, // NOTE: dynamic importAsyncComponentWrapperref
XTimeline,
XPostForm,
},
provide() {
return {
navHook: (path) => {
switch (path) {
case '/timeline/home': this.tl = 'home'; return;
case '/timeline/local': this.tl = 'local'; return;
case '/timeline/social': this.tl = 'social'; return;
case '/timeline/global': this.tl = 'global'; return;
default:
if (path.startsWith('/channels/')) {
this.tl = `channel:${ path.replace('/channels/', '') }`;
return;
}
//os.pageWindow(path);
this.$refs.side.navigate(path);
break;
}
},
sideViewHook: (path) => {
this.$refs.side.navigate(path);
}
};
},
data() {
return {
tl: 'home',
lists: null,
antennas: null,
followedChannels: null,
featuredChannels: null,
menuDef: sidebarDef,
faLayerGroup, faBars, faBell, faHome, faCircle, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog,
};
},
created() {
os.api('users/lists/list').then(lists => {
this.lists = lists;
});
os.api('antennas/list').then(antennas => {
this.antennas = antennas;
});
os.api('channels/followed').then(channels => {
this.followedChannels = channels;
});
os.api('channels/featured').then(channels => {
this.featuredChannels = channels;
});
},
methods: {
showMenu() {
this.$refs.menu.show();
},
post() {
os.post();
},
top() {
window.scroll({ top: 0, behavior: 'smooth' });
},
onTransition() {
if (window._scroll) window._scroll();
},
onHeaderClick() {
window.scroll({ top: 0, behavior: 'smooth' });
},
onContextmenu(e) {
const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true;
if (el.parentElement) {
return isLink(el.parentElement);
}
};
if (isLink(e.target)) return;
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return;
if (window.getSelection().toString() !== '') return;
const path = this.$route.path;
os.contextMenu([{
type: 'label',
text: path,
}, {
icon: faColumns,
text: this.$ts.openInSideView,
action: () => {
this.$refs.side.navigate(path);
}
}, {
icon: faWindowMaximize,
text: this.$ts.openInWindow,
action: () => {
os.pageWindow(path);
}
}], e);
},
}
});
</script>
<style lang="scss" scoped>
.mk-app {
$header-height: 54px; // TODO:
$ui-font-size: 1em; // TODO:
// 100vh ... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
min-height: calc(var(--vh, 1vh) * 100);
box-sizing: border-box;
display: flex;
> .nav {
display: flex;
flex-direction: column;
width: 250px;
height: 100vh;
border-right: solid 1px var(--divider);
> .header, > .footer {
$padding: 8px;
display: flex;
z-index: 1000;
height: $header-height;
padding: $padding;
box-sizing: border-box;
line-height: ($header-height - ($padding * 2));
user-select: none;
&.header {
border-bottom: solid 1px var(--divider);
}
&.footer {
border-top: solid 1px var(--divider);
}
> .left {
> .account {
display: flex;
align-items: center;
padding: 0 8px;
> .avatar {
width: 26px;
height: 26px;
margin-right: 8px;
}
}
}
> .right {
margin-left: auto;
> .item {
height: ($header-height - ($padding * 2));
width: ($header-height - ($padding * 2));
padding: 10px;
box-sizing: border-box;
margin-right: 4px;
//opacity: 0.6;
position: relative;
> i {
position: absolute;
top: 8px;
right: 8px;
color: var(--indicator);
font-size: 8px;
line-height: 8px;
animation: blink 1s infinite;
}
}
}
}
> .body {
flex: 1;
min-width: 0;
overflow: auto;
> .container {
& + .container {
margin-top: 16px;
}
> .header {
font-size: 0.9em;
padding: 8px 16px;
opacity: 0.7;
}
> .body {
padding: 0 8px;
> .item {
display: block;
padding: 6px 8px;
border-radius: 4px;
&:hover {
text-decoration: none;
background: rgba(0, 0, 0, 0.05);
}
&.active, &.active:hover {
background: var(--accent);
color: #fff;
}
&.read {
opacity: 0.5;
}
> .icon {
margin-right: 6px;
opacity: 0.6;
}
}
}
}
}
}
> .main {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
height: 100vh;
position: relative;
background: var(--panel);
> .header {
$padding: 8px;
z-index: 1000;
height: $header-height;
padding: $padding;
box-sizing: border-box;
line-height: ($header-height - ($padding * 2));
font-weight: bold;
background-color: var(--panel);
border-bottom: solid 1px var(--divider);
user-select: none;
> div {
display: flex;
> .icon {
height: ($header-height - ($padding * 2));
width: ($header-height - ($padding * 2));
padding: 10px;
box-sizing: border-box;
margin-right: 4px;
opacity: 0.6;
}
}
}
> .footer {
padding: 16px;
}
> .body {
flex: 1;
min-width: 0;
overflow: auto;
}
}
> .side {
border-left: solid 1px var(--divider);
}
}
</style>

View File

@ -0,0 +1,115 @@
<template>
<header class="dehvdgxo">
<MkA class="name" :to="userPage(note.user)" v-user-preview="note.user.id">
<MkUserName :user="note.user"/>
</MkA>
<span class="is-bot" v-if="note.user.isBot">bot</span>
<span class="username"><MkAcct :user="note.user"/></span>
<span class="admin" v-if="note.user.isAdmin"><Fa :icon="faBookmark"/></span>
<span class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><Fa :icon="farBookmark"/></span>
<div class="info">
<span class="mobile" v-if="note.viaMobile"><Fa :icon="faMobileAlt"/></span>
<MkA class="created-at" :to="notePage(note)">
<MkTime :time="note.createdAt"/>
</MkA>
<span class="visibility" v-if="note.visibility !== 'public'">
<Fa v-if="note.visibility === 'home'" :icon="faHome"/>
<Fa v-if="note.visibility === 'followers'" :icon="faUnlock"/>
<Fa v-if="note.visibility === 'specified'" :icon="faEnvelope"/>
</span>
<span class="localOnly" v-if="note.localOnly"><Fa :icon="faBiohazard"/></span>
</div>
</header>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { faHome, faUnlock, faEnvelope, faMobileAlt, faBookmark, faBiohazard } from '@fortawesome/free-solid-svg-icons';
import { faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons';
import notePage from '@/filters/note';
import { userPage } from '@/filters/user';
import * as os from '@/os';
export default defineComponent({
props: {
note: {
type: Object,
required: true
},
},
data() {
return {
faHome, faUnlock, faEnvelope, faMobileAlt, faBookmark, farBookmark, faBiohazard
};
},
methods: {
notePage,
userPage
}
});
</script>
<style lang="scss" scoped>
.dehvdgxo {
display: flex;
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;
}
}
> .is-bot {
flex-shrink: 0;
align-self: center;
margin: 0 .5em 0 0;
padding: 1px 6px;
font-size: 80%;
border: solid 1px var(--divider);
border-radius: 3px;
}
> .admin,
> .moderator {
margin-right: 0.5em;
color: var(--badge);
}
> .username {
margin: 0 .5em 0 0;
overflow: hidden;
text-overflow: ellipsis;
}
> .info {
font-size: 0.9em;
opacity: 0.7;
> .mobile {
margin-right: 8px;
}
> .visibility {
margin-left: 8px;
}
> .localOnly {
margin-left: 8px;
}
}
}
</style>

View File

@ -0,0 +1,112 @@
<template>
<div class="hduudsxk">
<MkAvatar class="avatar" :user="note.user"/>
<div class="main">
<XNoteHeader class="header" :note="note" :mini="true"/>
<div class="body">
<p v-if="note.cw != null" class="cw">
<span class="text" v-if="note.cw != ''">{{ note.cw }}</span>
<XCwButton v-model:value="showContent" :note="note"/>
</p>
<div class="content" v-show="note.cw == null || showContent">
<XSubNote-content class="text" :note="note"/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import XNoteHeader from './note-header.vue';
import XSubNoteContent from './sub-note-content.vue';
import XCwButton from '@/components/cw-button.vue';
import * as os from '@/os';
export default defineComponent({
components: {
XNoteHeader,
XSubNoteContent,
XCwButton,
},
props: {
note: {
type: Object,
required: true
}
},
data() {
return {
showContent: false
};
}
});
</script>
<style lang="scss" scoped>
.hduudsxk {
display: flex;
margin: 0;
padding: 0;
overflow: hidden;
font-size: 0.95em;
> .avatar {
@media (min-width: 350px) {
margin: 0 10px 0 0;
width: 44px;
height: 44px;
}
@media (min-width: 500px) {
margin: 0 12px 0 0;
width: 48px;
height: 48px;
}
}
> .avatar {
flex-shrink: 0;
display: block;
margin: 0 10px 0 0;
width: 40px;
height: 40px;
border-radius: 8px;
}
> .main {
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 {
cursor: default;
margin: 0;
padding: 0;
}
}
}
}
}
</style>

View File

@ -0,0 +1,137 @@
<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" />
<XCwButton v-model:value="showContent" :note="note"/>
</p>
<div class="content" v-show="note.cw == null || showContent">
<XSubNote-content class="text" :note="note"/>
</div>
</div>
</div>
</div>
<XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :children="true"/>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import XNoteHeader from './note-header.vue';
import XSubNoteContent from './sub-note-content.vue';
import XCwButton from '@/components/cw-button.vue';
import * as os from '@/os';
export default defineComponent({
name: 'XSub',
components: {
XNoteHeader,
XSubNoteContent,
XCwButton,
},
props: {
note: {
type: Object,
required: true
},
detail: {
type: Boolean,
required: false,
default: false
},
children: {
type: Boolean,
required: false,
default: false
},
// TODO
truncate: {
type: Boolean,
default: true
}
},
data() {
return {
showContent: false,
replies: [],
};
},
created() {
if (this.detail) {
os.api('notes/children', {
noteId: this.note.id,
limit: 5
}).then(replies => {
this.replies = replies;
});
}
},
});
</script>
<style lang="scss" scoped>
.wrpstxzv {
padding: 16px 16px;
font-size: 0.8em;
&.children {
padding: 10px 0 0 16px;
font-size: 1em;
}
> .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 1px var(--divider);
margin-top: 10px;
}
}
</style>

1126
src/client/ui/chat/note.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,91 @@
<template>
<div class="" :ref="mounted">
<div class="_fullinfo" v-if="empty">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<div>{{ $ts.noNotes }}</div>
</div>
<MkError v-if="error" @retry="init()"/>
<div v-show="more && reversed" style="margin-bottom: var(--margin);">
<button class="_buttonPrimary" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template>
</button>
</div>
<XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed">
<XNote :note="note" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/>
</XList>
<div v-show="more && !reversed" style="margin-top: var(--margin);">
<button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template>
</button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import paging from '@/scripts/paging';
import XNote from './note.vue';
import XList from './date-separated-list.vue';
export default defineComponent({
components: {
XNote, XList,
},
mixins: [
paging({
before: (self) => {
self.$emit('before');
},
after: (self, e) => {
self.$emit('after', e);
}
}),
],
props: {
pagination: {
required: true
},
prop: {
type: String,
required: false
}
},
emits: ['before', 'after'],
computed: {
notes(): any[] {
return this.prop ? this.items.map(item => item[this.prop]) : this.items;
},
reversed(): boolean {
return this.pagination.reversed;
}
},
methods: {
updated(oldValue, newValue) {
const i = this.notes.findIndex(n => n === oldValue);
if (this.prop) {
this.items[i][this.prop] = newValue;
} else {
this.items[i] = newValue;
}
},
focus() {
this.$refs.notes.focus();
}
}
});
</script>

View File

@ -0,0 +1,771 @@
<template>
<div class="pxiwixjf"
@dragover.stop="onDragover"
@dragenter="onDragenter"
@dragleave="onDragleave"
@drop.stop="onDrop"
>
<div class="form">
<div class="with-quote" v-if="quoteId"><Fa icon="quote-left"/> {{ $ts.quoteAttached }}<button @click="quoteId = null"><Fa icon="times"/></button></div>
<div v-if="visibility === 'specified'" class="to-specified">
<span style="margin-right: 8px;">{{ $ts.recipient }}</span>
<div class="visibleUsers">
<span v-for="u in visibleUsers" :key="u.id">
<MkAcct :user="u"/>
<button class="_button" @click="removeVisibleUser(u)"><Fa :icon="faTimes"/></button>
</span>
<button @click="addVisibleUser" class="_buttonPrimary"><Fa :icon="faPlus" fixed-width/></button>
</div>
</div>
<input v-show="useCw" ref="cw" class="cw" v-model="cw" :placeholder="$ts.annotation" @keydown="onKeydown">
<textarea v-model="text" class="text" :class="{ withCw: useCw }" ref="text" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd" />
<XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
<XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/>
<footer>
<div class="left">
<button class="_button" @click="chooseFileFrom" v-tooltip="$ts.attachFile"><Fa :icon="faPhotoVideo"/></button>
<button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$ts.poll"><Fa :icon="faPollH"/></button>
<button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$ts.useCw"><Fa :icon="faEyeSlash"/></button>
<button class="_button" @click="insertMention" v-tooltip="$ts.mention"><Fa :icon="faAt"/></button>
<button class="_button" @click="insertEmoji" v-tooltip="$ts.emoji"><Fa :icon="faLaughSquint"/></button>
<button class="_button" @click="showActions" v-tooltip="$ts.plugin" v-if="postFormActions.length > 0"><Fa :icon="faPlug"/></button>
</div>
<div class="right">
<span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span>
<span class="local-only" v-if="localOnly"><Fa :icon="faBiohazard"/></span>
<button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$ts.visibility" :disabled="channel != null">
<span v-if="visibility === 'public'"><Fa :icon="faGlobe"/></span>
<span v-if="visibility === 'home'"><Fa :icon="faHome"/></span>
<span v-if="visibility === 'followers'"><Fa :icon="faUnlock"/></span>
<span v-if="visibility === 'specified'"><Fa :icon="faEnvelope"/></span>
</button>
<button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}<Fa :icon="reply ? faReply : renote ? faQuoteRight : faPaperPlane"/></button>
</div>
</footer>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, defineAsyncComponent } from 'vue';
import { faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons';
import { faEyeSlash, faLaughSquint } from '@fortawesome/free-regular-svg-icons';
import insertTextAtCursor from 'insert-text-at-cursor';
import { length } from 'stringz';
import { toASCII } from 'punycode';
import { parse } from '../../../mfm/parse';
import { host, url } from '@/config';
import { erase, unique } from '../../../prelude/array';
import extractMentions from '../../../misc/extract-mentions';
import getAcct from '../../../misc/acct/render';
import { formatTimeString } from '../../../misc/format-time-string';
import { Autocomplete } from '@/scripts/autocomplete';
import { noteVisibilities } from '../../../types';
import * as os from '@/os';
import { selectFile } from '@/scripts/select-file';
import { notePostInterruptors, postFormActions } from '@/store';
export default defineComponent({
components: {
XPostFormAttaches: defineAsyncComponent(() => import('@/components/post-form-attaches.vue')),
XPollEditor: defineAsyncComponent(() => import('@/components/poll-editor.vue'))
},
props: {
reply: {
type: Object,
required: false
},
renote: {
type: Object,
required: false
},
channel: {
type: String,
required: false
},
mention: {
type: Object,
required: false
},
specified: {
type: Object,
required: false
},
initialText: {
type: String,
required: false
},
initialNote: {
type: Object,
required: false
},
instant: {
type: Boolean,
required: false,
default: false
},
autofocus: {
type: Boolean,
required: false,
default: true
},
},
emits: ['posted', 'cancel', 'esc'],
data() {
return {
posting: false,
text: '',
files: [],
poll: null,
useCw: false,
cw: null,
localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly,
visibility: this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility,
visibleUsers: [],
autocomplete: null,
draghover: false,
quoteId: null,
recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
imeText: '',
postFormActions,
faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug
};
},
computed: {
draftKey(): string {
let key = this.channel ? `channel:${this.channel}` : '';
if (this.renote) {
key += `renote:${this.renote.id}`;
} else if (this.reply) {
key += `reply:${this.reply.id}`;
} else {
key += 'note';
}
return key;
},
placeholder(): string {
if (this.renote) {
return this.$ts._postForm.quotePlaceholder;
} else if (this.reply) {
return this.$ts._postForm.replyPlaceholder;
} else if (this.channel) {
return this.$ts._postForm.channelPlaceholder;
} else {
const xs = [
this.$ts._postForm._placeholders.a,
this.$ts._postForm._placeholders.b,
this.$ts._postForm._placeholders.c,
this.$ts._postForm._placeholders.d,
this.$ts._postForm._placeholders.e,
this.$ts._postForm._placeholders.f
];
return xs[Math.floor(Math.random() * xs.length)];
}
},
submitText(): string {
return this.renote
? this.$ts.quote
: this.reply
? this.$ts.reply
: this.$ts.note;
},
textLength(): number {
return length((this.text + this.imeText).trim());
},
canPost(): boolean {
return !this.posting &&
(1 <= this.textLength || 1 <= this.files.length || !!this.poll || !!this.renote) &&
(this.textLength <= this.max) &&
(!this.poll || this.poll.choices.length >= 2);
},
max(): number {
return this.$instance ? this.$instance.maxNoteTextLength : 1000;
}
},
mounted() {
if (this.initialText) {
this.text = this.initialText;
}
if (this.mention) {
this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`;
this.text += ' ';
}
if (this.reply && this.reply.user.host != null) {
this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `;
}
if (this.reply && this.reply.text != null) {
const ast = parse(this.reply.text);
for (const x of extractMentions(ast)) {
const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`;
//
if (this.$i.username == x.username && x.host == null) continue;
if (this.$i.username == x.username && x.host == host) continue;
//
if (this.text.indexOf(`${mention} `) != -1) continue;
this.text += `${mention} `;
}
}
if (this.channel) {
this.visibility = 'public';
this.localOnly = true; // TODO:
}
//
if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) {
this.visibility = this.reply.visibility;
if (this.reply.visibility === 'specified') {
os.api('users/show', {
userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$i.id && uid !== this.reply.userId)
}).then(users => {
this.visibleUsers.push(...users);
});
if (this.reply.userId !== this.$i.id) {
os.api('users/show', { userId: this.reply.userId }).then(user => {
this.visibleUsers.push(user);
});
}
}
}
if (this.specified) {
this.visibility = 'specified';
this.visibleUsers.push(this.specified);
}
// keep cw when reply
if (this.$store.state.keepCw && this.reply && this.reply.cw) {
this.useCw = true;
this.cw = this.reply.cw;
}
if (this.autofocus) {
this.focus();
this.$nextTick(() => {
this.focus();
});
}
// TODO: detach when unmount
new Autocomplete(this.$refs.text, this, { model: 'text' });
new Autocomplete(this.$refs.cw, this, { model: 'cw' });
this.$nextTick(() => {
// 稿
if (!this.instant && !this.mention && !this.specified) {
const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey];
if (draft) {
this.text = draft.data.text;
this.useCw = draft.data.useCw;
this.cw = draft.data.cw;
this.visibility = draft.data.visibility;
this.localOnly = draft.data.localOnly;
this.files = (draft.data.files || []).filter(e => e);
if (draft.data.poll) {
this.poll = draft.data.poll;
}
}
}
//
if (this.initialNote) {
const init = this.initialNote;
this.text = init.text ? init.text : '';
this.files = init.files;
this.cw = init.cw;
this.useCw = init.cw != null;
if (init.poll) {
this.poll = init.poll;
}
this.visibility = init.visibility;
this.localOnly = init.localOnly;
this.quoteId = init.renote ? init.renote.id : null;
}
this.$nextTick(() => this.watch());
});
},
methods: {
watch() {
this.$watch('text', () => this.saveDraft());
this.$watch('useCw', () => this.saveDraft());
this.$watch('cw', () => this.saveDraft());
this.$watch('poll', () => this.saveDraft());
this.$watch('files', () => this.saveDraft(), { deep: true });
this.$watch('visibility', () => this.saveDraft());
this.$watch('localOnly', () => this.saveDraft());
},
togglePoll() {
if (this.poll) {
this.poll = null;
} else {
this.poll = {
choices: ['', ''],
multiple: false,
expiresAt: null,
expiredAfter: null,
};
}
},
addTag(tag: string) {
insertTextAtCursor(this.$refs.text, ` #${tag} `);
},
focus() {
(this.$refs.text as any).focus();
},
chooseFileFrom(ev) {
selectFile(ev.currentTarget || ev.target, this.$ts.attachFile, true).then(files => {
for (const file of files) {
this.files.push(file);
}
});
},
detachFile(id) {
this.files = this.files.filter(x => x.id != id);
},
updateFiles(files) {
this.files = files;
},
updateFileSensitive(file, sensitive) {
this.files[this.files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
},
updateFileName(file, name) {
this.files[this.files.findIndex(x => x.id === file.id)].name = name;
},
upload(file: File, name?: string) {
os.upload(file, this.$store.state.uploadFolder, name).then(res => {
this.files.push(res);
});
},
onPollUpdate(poll) {
this.poll = poll;
this.saveDraft();
},
setVisibility() {
if (this.channel) {
// TODO: information dialog
return;
}
os.popup(import('@/components/visibility-picker.vue'), {
currentVisibility: this.visibility,
currentLocalOnly: this.localOnly,
src: this.$refs.visibilityButton
}, {
changeVisibility: visibility => {
this.visibility = visibility;
if (this.$store.state.rememberNoteVisibility) {
this.$store.set('visibility', visibility);
}
},
changeLocalOnly: localOnly => {
this.localOnly = localOnly;
if (this.$store.state.rememberNoteVisibility) {
this.$store.set('localOnly', localOnly);
}
}
}, 'closed');
},
addVisibleUser() {
os.selectUser().then(user => {
this.visibleUsers.push(user);
});
},
removeVisibleUser(user) {
this.visibleUsers = erase(user, this.visibleUsers);
},
clear() {
this.text = '';
this.files = [];
this.poll = null;
this.quoteId = null;
},
onKeydown(e: KeyboardEvent) {
if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
if (e.which === 27) this.$emit('esc');
},
onCompositionUpdate(e: CompositionEvent) {
this.imeText = e.data;
},
onCompositionEnd(e: CompositionEvent) {
this.imeText = '';
},
async onPaste(e: ClipboardEvent) {
for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) {
if (item.kind == 'file') {
const file = item.getAsFile();
const lio = file.name.lastIndexOf('.');
const ext = lio >= 0 ? file.name.slice(lio) : '';
const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
this.upload(file, formatted);
}
}
const paste = e.clipboardData.getData('text');
if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) {
e.preventDefault();
os.dialog({
type: 'info',
text: this.$ts.quoteQuestion,
showCancelButton: true
}).then(({ canceled }) => {
if (canceled) {
insertTextAtCursor(this.$refs.text, paste);
return;
}
this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
});
}
},
onDragover(e) {
if (!e.dataTransfer.items[0]) return;
const isFile = e.dataTransfer.items[0].kind == 'file';
const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
if (isFile || isDriveFile) {
e.preventDefault();
this.draghover = true;
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
}
},
onDragenter(e) {
this.draghover = true;
},
onDragleave(e) {
this.draghover = false;
},
onDrop(e): void {
this.draghover = false;
//
if (e.dataTransfer.files.length > 0) {
e.preventDefault();
for (const x of Array.from(e.dataTransfer.files)) this.upload(x);
return;
}
//#region
const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile != '') {
const file = JSON.parse(driveFile);
this.files.push(file);
e.preventDefault();
}
//#endregion
},
saveDraft() {
if (this.instant) return;
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
data[this.draftKey] = {
updatedAt: new Date(),
data: {
text: this.text,
useCw: this.useCw,
cw: this.cw,
visibility: this.visibility,
localOnly: this.localOnly,
files: this.files,
poll: this.poll
}
};
localStorage.setItem('drafts', JSON.stringify(data));
},
deleteDraft() {
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
delete data[this.draftKey];
localStorage.setItem('drafts', JSON.stringify(data));
},
async post() {
let data = {
text: this.text == '' ? undefined : this.text,
fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
replyId: this.reply ? this.reply.id : undefined,
renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined,
channelId: this.channel ? this.channel : undefined,
poll: this.poll,
cw: this.useCw ? this.cw || '' : undefined,
localOnly: this.localOnly,
visibility: this.visibility,
visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined,
viaMobile: os.isMobile
};
// plugin
if (notePostInterruptors.length > 0) {
for (const interruptor of notePostInterruptors) {
data = await interruptor.handler(JSON.parse(JSON.stringify(data)));
}
}
this.posting = true;
os.api('notes/create', data).then(() => {
this.clear();
this.$nextTick(() => {
this.deleteDraft();
this.$emit('posted');
if (this.text && this.text != '') {
const hashtags = parse(this.text).filter(x => x.node.type === 'hashtag').map(x => x.node.props.hashtag);
const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
}
this.posting = false;
});
}).catch(err => {
this.posting = false;
os.dialog({
type: 'error',
text: err.message + '\n' + (err as any).id,
});
});
},
cancel() {
this.$emit('cancel');
},
insertMention() {
os.selectUser().then(user => {
insertTextAtCursor(this.$refs.text, '@' + getAcct(user) + ' ');
});
},
async insertEmoji(ev) {
os.pickEmoji(ev.currentTarget || ev.target).then(emoji => {
insertTextAtCursor(this.$refs.text, emoji);
});
},
showActions(ev) {
os.modalMenu(postFormActions.map(action => ({
text: action.title,
action: () => {
action.handler({
text: this.text
}, (key, value) => {
if (key === 'text') { this.text = value; }
});
}
})), ev.currentTarget || ev.target);
}
}
});
</script>
<style lang="scss" scoped>
.pxiwixjf {
position: relative;
border: solid 1px var(--divider);
border-radius: 8px;
> .form {
> .preview {
padding: 16px;
}
> .with-quote {
margin: 0 0 8px 0;
color: var(--accent);
> button {
padding: 4px 8px;
color: var(--accentAlpha04);
&:hover {
color: var(--accentAlpha06);
}
&:active {
color: var(--accentDarken30);
}
}
}
> .to-specified {
padding: 6px 24px;
margin-bottom: 8px;
overflow: auto;
white-space: nowrap;
> .visibleUsers {
display: inline;
top: -1px;
font-size: 14px;
> button {
padding: 4px;
border-radius: 8px;
}
> span {
margin-right: 14px;
padding: 8px 0 8px 8px;
border-radius: 8px;
background: var(--X4);
> button {
padding: 4px 8px;
}
}
}
}
> .cw,
> .text {
display: block;
box-sizing: border-box;
padding: 16px;
margin: 0;
width: 100%;
font-size: 16px;
border: none;
border-radius: 0;
background: transparent;
color: var(--fg);
font-family: inherit;
&:focus {
outline: none;
}
&:disabled {
opacity: 0.5;
}
}
> .cw {
z-index: 1;
padding-bottom: 8px;
border-bottom: solid 1px var(--divider);
}
> .text {
max-width: 100%;
min-width: 100%;
min-height: 60px;
&.withCw {
padding-top: 8px;
}
}
> footer {
$height: 44px;
display: flex;
padding: 0 8px 8px 8px;
line-height: $height;
> .left {
> button {
display: inline-block;
padding: 0;
margin: 0;
font-size: 16px;
width: $height;
height: $height;
border-radius: 6px;
&:hover {
background: var(--X5);
}
&.active {
color: var(--accent);
}
}
}
> .right {
margin-left: auto;
> .text-count {
opacity: 0.7;
}
> .visibility {
width: $height;
margin: 0 8px;
& + .localOnly {
margin-left: 0 !important;
}
}
> .local-only {
margin: 0 0 0 12px;
opacity: 0.7;
}
> .submit {
margin: 0;
padding: 0 12px;
line-height: 34px;
font-weight: bold;
border-radius: 4px;
&:disabled {
opacity: 0.7;
}
> [data-icon] {
margin-left: 6px;
}
}
}
}
}
}
</style>

165
src/client/ui/chat/side.vue Normal file
View File

@ -0,0 +1,165 @@
<template>
<div class="qvzfzxam _narrow_" v-if="component">
<div class="container">
<header class="header" @contextmenu.prevent.stop="onContextmenu">
<button class="_button" @click="back()" v-if="history.length > 0"><Fa :icon="faChevronLeft"/></button>
<XHeader class="title" :info="pageInfo" :with-back="false" :center="false"/>
<button class="_button" @click="close()"><Fa :icon="faTimes"/></button>
</header>
<component :is="component" v-bind="props" :ref="changePage"/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { faTimes, faChevronLeft, faExpandAlt, faWindowMaximize, faExternalLinkAlt, faLink } from '@fortawesome/free-solid-svg-icons';
import XHeader from '../_common_/header.vue';
import * as os from '@/os';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { resolve } from '@/router';
import { url } from '@/config';
export default defineComponent({
components: {
XHeader
},
provide() {
return {
navHook: (path) => {
this.navigate(path);
}
};
},
data() {
return {
path: null,
component: null,
props: {},
pageInfo: null,
history: [],
faTimes, faChevronLeft,
};
},
computed: {
url(): string {
return url + this.path;
}
},
methods: {
changePage(page) {
if (page == null) return;
if (page.INFO) {
this.pageInfo = page.INFO;
}
},
navigate(path, record = true) {
if (record && this.path) this.history.push(this.path);
this.path = path;
const { component, props } = resolve(path);
this.component = component;
this.props = props;
},
back() {
this.navigate(this.history.pop(), false);
},
close() {
this.path = null;
this.component = null;
this.props = {};
},
onContextmenu(e) {
os.contextMenu([{
type: 'label',
text: this.path,
}, {
icon: faExpandAlt,
text: this.$ts.showInPage,
action: () => {
this.$router.push(this.path);
this.close();
}
}, {
icon: faWindowMaximize,
text: this.$ts.openInWindow,
action: () => {
os.pageWindow(this.path);
this.close();
}
}, null, {
icon: faExternalLinkAlt,
text: this.$ts.openInNewTab,
action: () => {
window.open(this.url, '_blank');
this.close();
}
}, {
icon: faLink,
text: this.$ts.copyLink,
action: () => {
copyToClipboard(this.url);
}
}], e);
}
}
});
</script>
<style lang="scss" scoped>
.qvzfzxam {
$header-height: 54px; // TODO:
--section-padding: 16px;
--margin: var(--marginHalf);
width: 390px;
> .container {
position: fixed;
width: 390px;
height: 100vh;
overflow: auto;
box-sizing: border-box;
> .header {
display: flex;
position: sticky;
z-index: 1000;
top: 0;
height: $header-height;
width: 100%;
line-height: $header-height;
font-weight: bold;
//background-color: var(--panel);
-webkit-backdrop-filter: blur(32px);
backdrop-filter: blur(32px);
background-color: var(--header);
border-bottom: solid 1px var(--divider);
box-sizing: border-box;
> ._button {
height: $header-height;
width: $header-height;
&:hover {
color: var(--fgHighlighted);
}
}
> .title {
flex: 1;
position: relative;
}
}
}
}
</style>

View File

@ -0,0 +1,64 @@
<template>
<div class="wrmlmaau">
<div class="body">
<span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span>
<span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span>
<MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><Fa :icon="faReply"/></MkA>
<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
<MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
</div>
<details v-if="note.files.length > 0">
<summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary>
<XMediaList :media-list="note.files"/>
</details>
<details v-if="note.poll">
<summary>{{ $ts.poll }}</summary>
<XPoll :note="note"/>
</details>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { faReply } from '@fortawesome/free-solid-svg-icons';
import XPoll from '@/components/poll.vue';
import XMediaList from '@/components/media-list.vue';
import * as os from '@/os';
export default defineComponent({
components: {
XPoll,
XMediaList,
},
props: {
note: {
type: Object,
required: true
}
},
data() {
return {
faReply
};
}
});
</script>
<style lang="scss" scoped>
.wrmlmaau {
overflow-wrap: break-word;
> .body {
> .reply {
margin-right: 6px;
color: var(--accent);
}
> .rp {
margin-left: 4px;
font-style: oblique;
color: var(--renote);
}
}
}
</style>

View File

@ -0,0 +1,190 @@
<template>
<XNotes ref="tl" :pagination="pagination" @queue="$emit('queue', $event)" v-follow="pagination.reversed"/>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import XNotes from './notes.vue';
import * as os from '@/os';
import * as sound from '@/scripts/sound';
import { scrollToBottom } from '@/scripts/scroll';
import follow from '@/directives/follow-append';
export default defineComponent({
components: {
XNotes
},
directives: {
follow
},
provide() {
return {
inChannel: this.src === 'channel'
};
},
props: {
src: {
type: String,
required: true
},
list: {
type: String,
required: false
},
antenna: {
type: String,
required: false
},
channel: {
type: String,
required: false
},
sound: {
type: Boolean,
required: false,
default: false,
}
},
emits: ['note', 'queue', 'before', 'after'],
data() {
return {
connection: null,
connection2: null,
pagination: null,
baseQuery: {
includeMyRenotes: this.$store.state.showMyRenotes,
includeRenotedMyNotes: this.$store.state.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.showLocalRenotes
},
query: {},
};
},
created() {
const prepend = note => {
(this.$refs.tl as any).prepend(note);
this.$emit('note');
if (this.sound) {
sound.play(note.userId === this.$i.id ? 'noteMy' : 'note');
}
};
const onUserAdded = () => {
(this.$refs.tl as any).reload();
};
const onUserRemoved = () => {
(this.$refs.tl as any).reload();
};
const onChangeFollowing = () => {
if (!this.$refs.tl.backed) {
this.$refs.tl.reload();
}
};
let endpoint;
let reversed = false;
if (this.src == 'antenna') {
endpoint = 'antennas/notes';
this.query = {
antennaId: this.antenna
};
this.connection = os.stream.connectToChannel('antenna', {
antennaId: this.antenna
});
this.connection.on('note', prepend);
} else if (this.src == 'home') {
endpoint = 'notes/timeline';
this.connection = os.stream.useSharedConnection('homeTimeline');
this.connection.on('note', prepend);
this.connection2 = os.stream.useSharedConnection('main');
this.connection2.on('follow', onChangeFollowing);
this.connection2.on('unfollow', onChangeFollowing);
} else if (this.src == 'local') {
endpoint = 'notes/local-timeline';
this.connection = os.stream.useSharedConnection('localTimeline');
this.connection.on('note', prepend);
} else if (this.src == 'social') {
endpoint = 'notes/hybrid-timeline';
this.connection = os.stream.useSharedConnection('hybridTimeline');
this.connection.on('note', prepend);
} else if (this.src == 'global') {
endpoint = 'notes/global-timeline';
this.connection = os.stream.useSharedConnection('globalTimeline');
this.connection.on('note', prepend);
} else if (this.src == 'mentions') {
endpoint = 'notes/mentions';
this.connection = os.stream.useSharedConnection('main');
this.connection.on('mention', prepend);
} else if (this.src == 'directs') {
endpoint = 'notes/mentions';
this.query = {
visibility: 'specified'
};
const onNote = note => {
if (note.visibility == 'specified') {
prepend(note);
}
};
this.connection = os.stream.useSharedConnection('main');
this.connection.on('mention', onNote);
} else if (this.src == 'list') {
endpoint = 'notes/user-list-timeline';
this.query = {
listId: this.list
};
this.connection = os.stream.connectToChannel('userList', {
listId: this.list
});
this.connection.on('note', prepend);
this.connection.on('userAdded', onUserAdded);
this.connection.on('userRemoved', onUserRemoved);
} else if (this.src == 'channel') {
endpoint = 'channels/timeline';
reversed = true;
this.query = {
channelId: this.channel
};
this.connection = os.stream.connectToChannel('channel', {
channelId: this.channel
});
this.connection.on('note', prepend);
}
this.pagination = {
endpoint: endpoint,
reversed,
limit: 10,
params: init => ({
untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
...this.baseQuery, ...this.query
})
};
},
mounted() {
},
beforeUnmount() {
this.connection.dispose();
if (this.connection2) this.connection2.dispose();
},
methods: {
focus() {
this.$refs.tl.focus();
},
}
});
</script>