Improve desktop UX (#4262)

* wip

* wip

* wip

* wip

* wip

* wip

* Merge

* wip

* wip

* wip

* wip

* wip

* wip
This commit is contained in:
syuilo 2019-02-15 05:08:59 +09:00 committed by GitHub
parent 38ca514f53
commit 53422ffcb2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 1132 additions and 1222 deletions

View file

@ -0,0 +1,83 @@
<template>
<div class="ecsvsegy" v-if="!fetching">
<sequential-entrance animation="entranceFromTop" delay="25">
<template v-for="favorite in favorites">
<mk-note-detail class="post" :note="favorite.note" :key="favorite.note.id"/>
</template>
</sequential-entrance>
<div class="more" v-if="existMore">
<ui-button inline @click="more">{{ $t('@.load-more') }}</ui-button>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import Progress from '../../../common/scripts/loading';
export default Vue.extend({
i18n: i18n('.vue'),
data() {
return {
fetching: true,
favorites: [],
existMore: false,
moreFetching: false
};
},
created() {
this.fetch();
},
methods: {
fetch() {
Progress.start();
this.fetching = true;
this.$root.api('i/favorites', {
limit: 11
}).then(favorites => {
if (favorites.length == 11) {
this.existMore = true;
favorites.pop();
}
this.favorites = favorites;
this.fetching = false;
Progress.done();
});
},
more() {
this.moreFetching = true;
this.$root.api('i/favorites', {
limit: 11,
untilId: this.favorites[this.favorites.length - 1].id
}).then(favorites => {
if (favorites.length == 11) {
this.existMore = true;
favorites.pop();
} else {
this.existMore = false;
}
this.favorites = this.favorites.concat(favorites);
this.moreFetching = false;
});
}
}
});
</script>
<style lang="stylus" scoped>
.ecsvsegy
margin 0 auto
> * > .post
margin-bottom 16px
> .more
margin 32px 16px 16px 16px
text-align center
</style>

View file

@ -0,0 +1,400 @@
<template>
<component :is="customize ? 'mk-dummy' : 'mk-ui'" v-hotkey.global="keymap" v-if="$store.getters.isSignedIn || $route.name != 'index'">
<div class="wqsofvpm" :data-customize="customize">
<div class="customize" v-if="customize">
<a @click="done()"><fa icon="check"/>{{ $t('done') }}</a>
<div>
<div class="adder">
<p>{{ $t('add-widget') }}</p>
<select v-model="widgetAdderSelected">
<option value="profile">{{ $t('@.widgets.profile') }}</option>
<option value="analog-clock">{{ $t('@.widgets.analog-clock') }}</option>
<option value="calendar">{{ $t('@.widgets.calendar') }}</option>
<option value="timemachine">{{ $t('@.widgets.timemachine') }}</option>
<option value="activity">{{ $t('@.widgets.activity') }}</option>
<option value="rss">{{ $t('@.widgets.rss') }}</option>
<option value="trends">{{ $t('@.widgets.trends') }}</option>
<option value="photo-stream">{{ $t('@.widgets.photo-stream') }}</option>
<option value="slideshow">{{ $t('@.widgets.slideshow') }}</option>
<option value="version">{{ $t('@.widgets.version') }}</option>
<option value="broadcast">{{ $t('@.widgets.broadcast') }}</option>
<option value="notifications">{{ $t('@.widgets.notifications') }}</option>
<option value="users">{{ $t('@.widgets.users') }}</option>
<option value="polls">{{ $t('@.widgets.polls') }}</option>
<option value="post-form">{{ $t('@.widgets.post-form') }}</option>
<option value="messaging">{{ $t('@.widgets.messaging') }}</option>
<option value="memo">{{ $t('@.widgets.memo') }}</option>
<option value="hashtags">{{ $t('@.widgets.hashtags') }}</option>
<option value="posts-monitor">{{ $t('@.widgets.posts-monitor') }}</option>
<option value="server">{{ $t('@.widgets.server') }}</option>
<option value="nav">{{ $t('@.widgets.nav') }}</option>
<option value="tips">{{ $t('@.widgets.tips') }}</option>
</select>
<button @click="addWidget">{{ $t('add') }}</button>
</div>
<div class="trash">
<x-draggable v-model="trash" :options="{ group: 'x' }" @add="onTrash"></x-draggable>
<p>{{ $t('@.trash') }}</p>
</div>
</div>
</div>
<div class="main" :class="{ side: widgets.left.length == 0 || widgets.right.length == 0 }">
<template v-if="customize">
<x-draggable v-for="place in ['left', 'right']"
:list="widgets[place]"
:class="place"
:data-place="place"
:options="{ group: 'x', animation: 150 }"
@sort="onWidgetSort"
:key="place"
>
<div v-for="widget in widgets[place]" class="customize-container" :key="widget.id" @contextmenu.stop.prevent="onWidgetContextmenu(widget.id)">
<component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true" platform="desktop"/>
</div>
</x-draggable>
<div class="main">
<a @click="hint">{{ $t('@.customization-tips.title') }}</a>
<div>
<x-timeline/>
</div>
</div>
</template>
<template v-else>
<div v-for="place in ['left', 'right']" :class="place">
<component v-for="widget in widgets[place]" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" platform="desktop"/>
</div>
<div class="main">
<router-view ref="content"></router-view>
</div>
</template>
</div>
</div>
</component>
<x-welcome v-else/>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import * as XDraggable from 'vuedraggable';
import * as uuid from 'uuid';
import XWelcome from '../pages/welcome.vue';
export default Vue.extend({
i18n: i18n('desktop/views/components/home.vue'),
components: {
XDraggable,
XWelcome
},
data() {
return {
customize: window.location.search == '?customize',
connection: null,
widgetAdderSelected: null,
trash: [],
view: null
};
},
computed: {
home(): any[] {
if (this.$store.getters.isSignedIn) {
return this.$store.state.settings.home || [];
} else {
return [{
name: 'instance',
place: 'right'
}];
}
},
left(): any[] {
return this.home.filter(w => w.place == 'left');
},
right(): any[] {
return this.home.filter(w => w.place == 'right');
},
widgets(): any {
return {
left: this.left,
right: this.right
};
},
keymap(): any {
return {
't': this.focus
};
}
},
created() {
if (this.$store.getters.isSignedIn) {
const defaultDesktopHomeWidgets = {
left: [
'profile',
'calendar',
'activity',
'rss',
'hashtags',
'photo-stream',
'version'
],
right: [
'customize',
'broadcast',
'notifications',
'users',
'polls',
'server',
'nav',
'tips'
]
};
//#region Construct home data
const _defaultDesktopHomeWidgets = [];
for (const widget of defaultDesktopHomeWidgets.left) {
_defaultDesktopHomeWidgets.push({
name: widget,
id: uuid(),
place: 'left',
data: {}
});
}
for (const widget of defaultDesktopHomeWidgets.right) {
_defaultDesktopHomeWidgets.push({
name: widget,
id: uuid(),
place: 'right',
data: {}
});
}
//#endregion
if (this.$store.state.settings.home == null) {
this.$root.api('i/update_home', {
home: _defaultDesktopHomeWidgets
}).then(() => {
this.$store.commit('settings/setHome', _defaultDesktopHomeWidgets);
});
}
}
},
mounted() {
this.connection = this.$root.stream.useSharedConnection('main');
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
hint() {
this.$root.dialog({
title: this.$t('@.customization-tips.title'),
text: this.$t('@.customization-tips.paragraph')
});
},
onTlLoaded() {
this.$emit('loaded');
},
onWidgetContextmenu(widgetId) {
const w = (this.$refs[widgetId] as any)[0];
if (w.func) w.func();
},
onWidgetSort() {
this.saveHome();
},
onTrash(evt) {
this.saveHome();
},
addWidget() {
this.$store.dispatch('settings/addHomeWidget', {
name: this.widgetAdderSelected,
id: uuid(),
place: 'left',
data: {}
});
},
saveHome() {
const left = this.widgets.left;
const right = this.widgets.right;
this.$store.commit('settings/setHome', left.concat(right));
for (const w of left) w.place = 'left';
for (const w of right) w.place = 'right';
this.$root.api('i/update_home', {
home: this.home
});
},
done() {
location.href = '/';
},
focus() {
(this.$refs.content as any).focus();
}
}
});
</script>
<style lang="stylus" scoped>
.wqsofvpm
display block
&[data-customize]
padding-top 48px
background-image url('/assets/desktop/grid.svg')
> .main > .main
> a
display block
margin-bottom 8px
text-align center
> div
cursor not-allowed !important
> *
pointer-events none
&:not([data-customize])
> .main > *:not(.main):empty
display none
> .customize
position fixed
z-index 1000
top 0
left 0
width 100%
height 48px
color var(--text)
background var(--desktopHeaderBg)
box-shadow 0 1px 1px rgba(#000, 0.075)
> a
display block
position absolute
z-index 1001
top 0
right 0
padding 0 16px
line-height 48px
text-decoration none
color var(--primaryForeground)
background var(--primary)
transition background 0.1s ease
&:hover
background var(--primaryLighten10)
&:active
background var(--primaryDarken10)
transition background 0s ease
> [data-icon]
margin-right 8px
> div
display flex
margin 0 auto
max-width 1220px - 32px
> div
width 50%
&.adder
> p
display inline
line-height 48px
&.trash
border-left solid 1px var(--faceDivider)
> div
width 100%
height 100%
> p
position absolute
top 0
left 0
width 100%
line-height 48px
margin 0
text-align center
pointer-events none
> .main
display flex
justify-content center
margin 0 auto
max-width 1240px
> *
.customize-container
cursor move
border-radius 6px
&:hover
box-shadow 0 0 8px rgba(64, 120, 200, 0.3)
> *
pointer-events none
> .main
padding 16px
width calc(100% - 280px * 2)
order 2
&.side
> .main
width calc(100% - 280px)
max-width 680px
> *:not(.main)
width 280px
padding 16px 0 16px 0
> *:not(:last-child)
margin-bottom 16px
> .left
padding-left 16px
order 1
> .right
padding-right 16px
order 3
&.side
@media (max-width 1000px)
> *:not(.main)
display none
> .main
width 100%
max-width 700px
margin 0 auto
&:not(.side)
@media (max-width 1200px)
> *:not(.main)
display none
> .main
width 100%
max-width 700px
margin 0 auto
</style>

View file

@ -0,0 +1,63 @@
<template>
<div v-if="!fetching" class="kcthdwmv">
<mk-note-detail :note="note"/>
<footer>
<router-link v-if="note.next" :to="note.next"><fa icon="angle-left"/> {{ $t('next') }}</router-link>
<router-link v-if="note.prev" :to="note.prev">{{ $t('prev') }} <fa icon="angle-right"/></router-link>
</footer>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import Progress from '../../../common/scripts/loading';
export default Vue.extend({
i18n: i18n('desktop/views/pages/note.vue'),
data() {
return {
fetching: true,
note: null
};
},
watch: {
$route: 'fetch'
},
created() {
this.fetch();
},
methods: {
fetch() {
Progress.start();
this.fetching = true;
this.$root.api('notes/show', {
noteId: this.$route.params.note
}).then(note => {
this.note = note;
this.fetching = false;
Progress.done();
});
}
}
});
</script>
<style lang="stylus" scoped>
.kcthdwmv
text-align center
> footer
margin-top 16px
> a
display inline-block
margin 0 16px
> .mk-note-detail
margin 0 auto
width 640px
</style>

View file

@ -0,0 +1,117 @@
<template>
<div>
<p :class="$style.empty" v-if="!fetching && empty"><fa icon="search"/> {{ $t('no-posts-found', { q: $route.params.tag }) }}</p>
<mk-notes ref="timeline" :class="$style.notes" :more="existMore ? more : null"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import Progress from '../../../common/scripts/loading';
const limit = 20;
export default Vue.extend({
i18n: i18n('desktop/views/pages/tag.vue'),
data() {
return {
fetching: true,
moreFetching: false,
existMore: false,
offset: 0,
empty: false
};
},
watch: {
$route: 'fetch'
},
mounted() {
document.addEventListener('keydown', this.onDocumentKeydown);
window.addEventListener('scroll', this.onScroll, { passive: true });
this.fetch();
},
beforeDestroy() {
document.removeEventListener('keydown', this.onDocumentKeydown);
window.removeEventListener('scroll', this.onScroll);
},
methods: {
onDocumentKeydown(e) {
if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') {
if (e.which == 84) { // t
(this.$refs.timeline as any).focus();
}
}
},
fetch() {
this.fetching = true;
Progress.start();
(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
this.$root.api('notes/search_by_tag', {
limit: limit + 1,
offset: this.offset,
tag: this.$route.params.tag
}).then(notes => {
if (notes.length == 0) this.empty = true;
if (notes.length == limit + 1) {
notes.pop();
this.existMore = true;
}
res(notes);
this.fetching = false;
Progress.done();
}, rej);
}));
},
more() {
this.offset += limit;
const promise = this.$root.api('notes/search_by_tag', {
limit: limit + 1,
offset: this.offset,
tag: this.$route.params.tag
});
promise.then(notes => {
if (notes.length == limit + 1) {
notes.pop();
} else {
this.existMore = false;
}
for (const n of notes) {
(this.$refs.timeline as any).append(n);
}
this.moreFetching = false;
});
return promise;
}
}
});
</script>
<style lang="stylus" module>
.notes
background var(--face)
box-shadow var(--shadow)
border-radius var(--round)
overflow hidden
overflow hidden
.empty
display block
margin 0 auto
padding 32px
max-width 400px
text-align center
color #999
> [data-icon]
display block
margin-bottom 16px
font-size 3em
color #ccc
</style>

View file

@ -0,0 +1,195 @@
<template>
<div class="mk-timeline-core">
<mk-friends-maker v-if="src == 'home' && alone"/>
<mk-notes ref="timeline" :more="existMore ? more : null">
<p :class="$style.empty" slot="empty">
<fa :icon="['far', 'comments']"/>{{ $t('empty') }}
</p>
</mk-notes>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
const fetchLimit = 10;
export default Vue.extend({
i18n: i18n('desktop/views/components/timeline.core.vue'),
props: {
src: {
type: String,
required: true
},
tagTl: {
required: false
}
},
data() {
return {
fetching: true,
moreFetching: false,
existMore: false,
connection: null,
date: null,
baseQuery: {
includeMyRenotes: this.$store.state.settings.showMyRenotes,
includeRenotedMyNotes: this.$store.state.settings.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.settings.showLocalRenotes
},
query: {},
endpoint: null
};
},
computed: {
alone(): boolean {
return this.$store.state.i.followingCount == 0;
},
canFetchMore(): boolean {
return !this.moreFetching && !this.fetching && this.existMore;
}
},
mounted() {
const prepend = note => {
(this.$refs.timeline as any).prepend(note);
};
if (this.src == 'tag') {
this.endpoint = 'notes/search_by_tag';
this.query = {
query: this.tagTl.query
};
this.connection = this.$root.stream.connectToChannel('hashtag', { q: this.tagTl.query });
this.connection.on('note', prepend);
} else if (this.src == 'home') {
this.endpoint = 'notes/timeline';
const onChangeFollowing = () => {
this.fetch();
};
this.connection = this.$root.stream.useSharedConnection('homeTimeline');
this.connection.on('note', prepend);
this.connection.on('follow', onChangeFollowing);
this.connection.on('unfollow', onChangeFollowing);
} else if (this.src == 'local') {
this.endpoint = 'notes/local-timeline';
this.connection = this.$root.stream.useSharedConnection('localTimeline');
this.connection.on('note', prepend);
} else if (this.src == 'hybrid') {
this.endpoint = 'notes/hybrid-timeline';
this.connection = this.$root.stream.useSharedConnection('hybridTimeline');
this.connection.on('note', prepend);
} else if (this.src == 'global') {
this.endpoint = 'notes/global-timeline';
this.connection = this.$root.stream.useSharedConnection('globalTimeline');
this.connection.on('note', prepend);
} else if (this.src == 'mentions') {
this.endpoint = 'notes/mentions';
this.connection = this.$root.stream.useSharedConnection('main');
this.connection.on('mention', prepend);
} else if (this.src == 'messages') {
this.endpoint = 'notes/mentions';
this.query = {
visibility: 'specified'
};
const onNote = note => {
if (note.visibility == 'specified') {
prepend(note);
}
};
this.connection = this.$root.stream.useSharedConnection('main');
this.connection.on('mention', onNote);
}
this.fetch();
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
fetch() {
this.fetching = true;
(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
this.$root.api(this.endpoint, Object.assign({
limit: fetchLimit + 1,
untilDate: this.date ? this.date.getTime() : undefined
}, this.baseQuery, this.query)).then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
this.existMore = true;
}
res(notes);
this.fetching = false;
this.$emit('loaded');
}, rej);
}));
},
more() {
if (!this.canFetchMore) return;
this.moreFetching = true;
const promise = this.$root.api(this.endpoint, Object.assign({
limit: fetchLimit + 1,
untilId: (this.$refs.timeline as any).tail().id
}, this.baseQuery, this.query));
promise.then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
} else {
this.existMore = false;
}
for (const n of notes) {
(this.$refs.timeline as any).append(n);
}
this.moreFetching = false;
});
return promise;
},
focus() {
(this.$refs.timeline as any).focus();
},
warp(date) {
this.date = date;
this.fetch();
}
}
});
</script>
<style lang="stylus" scoped>
.mk-timeline-core
> .mk-friends-maker
border-bottom solid var(--lineWidth) #eee
</style>
<style lang="stylus" module>
.empty
display block
margin 0 auto
padding 32px
max-width 400px
text-align center
color #999
> [data-icon]
display block
margin-bottom 16px
font-size 3em
color #ccc
</style>

View file

@ -0,0 +1,273 @@
<template>
<div class="mk-timeline">
<mk-post-form class="form" v-if="$store.state.settings.showPostFormOnTopOfTl"/>
<div class="main">
<header>
<span :data-active="src == 'home'" @click="src = 'home'"><fa icon="home"/> {{ $t('home') }}</span>
<span :data-active="src == 'local'" @click="src = 'local'" v-if="enableLocalTimeline"><fa :icon="['far', 'comments']"/> {{ $t('local') }}</span>
<span :data-active="src == 'hybrid'" @click="src = 'hybrid'" v-if="enableLocalTimeline"><fa icon="share-alt"/> {{ $t('hybrid') }}</span>
<span :data-active="src == 'global'" @click="src = 'global'" v-if="enableGlobalTimeline"><fa icon="globe"/> {{ $t('global') }}</span>
<span :data-active="src == 'tag'" @click="src = 'tag'" v-if="tagTl"><fa icon="hashtag"/> {{ tagTl.title }}</span>
<span :data-active="src == 'list'" @click="src = 'list'" v-if="list"><fa icon="list"/> {{ list.title }}</span>
<div class="buttons">
<button :data-active="src == 'mentions'" @click="src = 'mentions'" :title="$t('mentions')"><fa icon="at"/><i class="badge" v-if="$store.state.i.hasUnreadMentions"><fa icon="circle"/></i></button>
<button :data-active="src == 'messages'" @click="src = 'messages'" :title="$t('messages')"><fa :icon="['far', 'envelope']"/><i class="badge" v-if="$store.state.i.hasUnreadSpecifiedNotes"><fa icon="circle"/></i></button>
<button @click="chooseTag" :title="$t('hashtag')" ref="tagButton"><fa icon="hashtag"/></button>
<button @click="chooseList" :title="$t('list')" ref="listButton"><fa icon="list"/></button>
</div>
</header>
<x-core v-if="src == 'home'" ref="tl" key="home" src="home"/>
<x-core v-if="src == 'local'" ref="tl" key="local" src="local"/>
<x-core v-if="src == 'hybrid'" ref="tl" key="hybrid" src="hybrid"/>
<x-core v-if="src == 'global'" ref="tl" key="global" src="global"/>
<x-core v-if="src == 'mentions'" ref="tl" key="mentions" src="mentions"/>
<x-core v-if="src == 'messages'" ref="tl" key="messages" src="messages"/>
<x-core v-if="src == 'tag'" ref="tl" key="tag" src="tag" :tag-tl="tagTl"/>
<mk-user-list-timeline v-if="src == 'list'" ref="tl" :key="list.id" :list="list"/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../i18n';
import XCore from './timeline.core.vue';
import Menu from '../../../common/views/components/menu.vue';
import MkSettingsWindow from '../components/settings-window.vue';
export default Vue.extend({
i18n: i18n('desktop/views/components/timeline.vue'),
components: {
XCore
},
data() {
return {
src: 'home',
list: null,
tagTl: null,
enableLocalTimeline: false,
enableGlobalTimeline: false,
};
},
watch: {
src() {
this.saveSrc();
},
list(x) {
this.saveSrc();
if (x != null) this.tagTl = null;
},
tagTl(x) {
this.saveSrc();
if (x != null) this.list = null;
}
},
created() {
this.$root.getMeta().then((meta: Record<string, any>) => {
if (!(
this.enableGlobalTimeline = !meta.disableGlobalTimeline || this.$store.state.i.isModerator || this.$store.state.i.isAdmin
) && this.src === 'global') this.src = 'local';
if (!(
this.enableLocalTimeline = !meta.disableLocalTimeline || this.$store.state.i.isModerator || this.$store.state.i.isAdmin
) && ['local', 'hybrid'].includes(this.src)) this.src = 'home';
});
if (this.$store.state.device.tl) {
this.src = this.$store.state.device.tl.src;
if (this.src == 'list') {
this.list = this.$store.state.device.tl.arg;
} else if (this.src == 'tag') {
this.tagTl = this.$store.state.device.tl.arg;
}
} else if (this.$store.state.i.followingCount == 0) {
this.src = 'hybrid';
}
},
mounted() {
(this.$refs.tl as any).$once('loaded', () => {
this.$emit('loaded');
});
},
methods: {
saveSrc() {
this.$store.commit('device/setTl', {
src: this.src,
arg: this.src == 'list' ? this.list : this.tagTl
});
},
focus() {
(this.$refs.tl as any).focus();
},
warp(date) {
(this.$refs.tl as any).warp(date);
},
async chooseList() {
const lists = await this.$root.api('users/lists/list');
let menu = [{
icon: 'plus',
text: this.$t('add-list'),
action: () => {
this.$root.dialog({
title: this.$t('list-name'),
input: true
}).then(async ({ canceled, result: title }) => {
if (canceled) return;
const list = await this.$root.api('users/lists/create', {
title
});
this.list = list;
this.src = 'list';
});
}
}];
if (lists.length > 0) {
menu.push(null);
}
menu = menu.concat(lists.map(list => ({
icon: 'list',
text: list.title,
action: () => {
this.list = list;
this.src = 'list';
}
})));
this.$root.new(Menu, {
source: this.$refs.listButton,
items: menu
});
},
chooseTag() {
let menu = [{
icon: 'plus',
text: this.$t('add-tag-timeline'),
action: () => {
this.$root.new(MkSettingsWindow, {
initialPage: 'hashtags'
});
}
}];
if (this.$store.state.settings.tagTimelines.length > 0) {
menu.push(null);
}
menu = menu.concat(this.$store.state.settings.tagTimelines.map(t => ({
icon: 'hashtag',
text: t.title,
action: () => {
this.tagTl = t;
this.src = 'tag';
}
})));
this.$root.new(Menu, {
source: this.$refs.tagButton,
items: menu
});
}
}
});
</script>
<style lang="stylus" scoped>
.mk-timeline
> .form
margin-bottom 16px
box-shadow var(--shadow)
border-radius var(--round)
> .main
background var(--face)
box-shadow var(--shadow)
border-radius var(--round)
overflow hidden
> header
padding 0 8px
z-index 10
background var(--faceHeader)
box-shadow 0 var(--lineWidth) var(--desktopTimelineHeaderShadow)
> .buttons
position absolute
z-index 2
top 0
right 0
padding-right 8px
> button
padding 0 8px
font-size 0.9em
line-height 42px
color var(--faceTextButton)
> .badge
position absolute
top -4px
right 4px
font-size 10px
color var(--notificationIndicator)
&:hover
color var(--faceTextButtonHover)
&[data-active]
color var(--primary)
cursor default
&:before
content ""
display block
position absolute
bottom 0
left 0
width 100%
height 2px
background var(--primary)
> span
display inline-block
padding 0 10px
line-height 42px
font-size 12px
user-select none
&[data-active]
color var(--primary)
cursor default
font-weight bold
&:before
content ""
display block
position absolute
bottom 0
left -8px
width calc(100% + 16px)
height 2px
background var(--primary)
&:not([data-active])
color var(--desktopTimelineSrc)
cursor pointer
&:hover
color var(--desktopTimelineSrcHover)
</style>

View file

@ -0,0 +1,84 @@
<template>
<div class="vahgrswmbzfdlmomxnqftuueyvwaafth">
<p class="title"><fa icon="users"/>{{ $t('title') }}</p>
<p class="initializing" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('loading') }}<mk-ellipsis/></p>
<div v-if="!fetching && users.length > 0">
<router-link v-for="user in users" :to="user | userPage" :key="user.id">
<img :src="user.avatarUrl" :alt="user | userName" v-user-preview="user.id"/>
</router-link>
</div>
<p class="empty" v-if="!fetching && users.length == 0">{{ $t('no-users') }}</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
export default Vue.extend({
i18n: i18n('desktop/views/pages/user/user.followers-you-know.vue'),
props: ['user'],
data() {
return {
users: [],
fetching: true
};
},
mounted() {
this.$root.api('users/followers', {
userId: this.user.id,
iknow: true,
limit: 16
}).then(x => {
this.users = x.users;
this.fetching = false;
});
}
});
</script>
<style lang="stylus" scoped>
.vahgrswmbzfdlmomxnqftuueyvwaafth
background var(--face)
box-shadow var(--shadow)
border-radius var(--round)
> .title
z-index 1
margin 0
padding 0 16px
line-height 42px
font-size 0.9em
font-weight bold
color var(--faceHeaderText)
box-shadow 0 1px rgba(#000, 0.07)
> i
margin-right 4px
> div
padding 8px
> a
display inline-block
margin 4px
> img
display inline-block
text-align center
width 48px
height 48px
vertical-align bottom
border-radius 100%
> .initializing
> .empty
margin 0
padding 16px
text-align center
color var(--text)
> i
margin-right 4px
</style>

View file

@ -0,0 +1,112 @@
<template>
<div class="hozptpaliadatkehcmcayizwzwwctpbc">
<p class="title"><fa icon="users"/>{{ $t('title') }}</p>
<p class="initializing" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('loading') }}<mk-ellipsis/></p>
<template v-if="!fetching && users.length != 0">
<div class="user" v-for="friend in users">
<mk-avatar class="avatar" :user="friend"/>
<div class="body">
<router-link class="name" :to="friend | userPage" v-user-preview="friend.id"><mk-user-name :user="friend"/></router-link>
<p class="username">@{{ friend | acct }}</p>
</div>
</div>
</template>
<p class="empty" v-if="!fetching && users.length == 0">{{ $t('no-users') }}</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
export default Vue.extend({
i18n: i18n('desktop/views/pages/user/user.friends.vue'),
props: ['user'],
data() {
return {
users: [],
fetching: true
};
},
mounted() {
this.$root.api('users/get_frequently_replied_users', {
userId: this.user.id,
limit: 4
}).then(docs => {
this.users = docs.map(doc => doc.user);
this.fetching = false;
});
}
});
</script>
<style lang="stylus" scoped>
.hozptpaliadatkehcmcayizwzwwctpbc
background var(--face)
box-shadow var(--shadow)
border-radius var(--round)
overflow hidden
> .title
z-index 1
margin 0
padding 0 16px
line-height 42px
font-size 0.9em
font-weight bold
background var(--faceHeader)
color var(--faceHeaderText)
box-shadow 0 1px rgba(#000, 0.07)
> i
margin-right 4px
> .initializing
> .empty
margin 0
padding 16px
text-align center
color var(--text)
> i
margin-right 4px
> .user
padding 16px
border-bottom solid 1px var(--faceDivider)
&:last-child
border-bottom none
&:after
content ""
display block
clear both
> .avatar
display block
float left
margin 0 12px 0 0
width 42px
height 42px
border-radius 8px
> .body
float left
width calc(100% - 54px)
> .name
margin 0
font-size 16px
line-height 24px
color var(--text)
> .username
display block
margin 0
font-size 15px
line-height 16px
color var(--text)
opacity 0.7
</style>

View file

@ -0,0 +1,272 @@
<template>
<div class="header" :data-is-dark-background="user.bannerUrl != null">
<div class="banner-container" :style="style">
<div class="banner" ref="banner" :style="style" @click="onBannerClick"></div>
<div class="fade"></div>
<div class="title">
<p class="name">
<mk-user-name :user="user"/>
</p>
<div>
<span class="username"><mk-acct :user="user" :detail="true" /></span>
<span v-if="user.isBot" :title="$t('is-bot')"><fa icon="robot"/></span>
</div>
</div>
</div>
<mk-avatar class="avatar" :user="user" :disable-preview="true"/>
<div class="body">
<div class="actions" v-if="$store.getters.isSignedIn">
<template v-if="$store.state.i.id != user.id">
<p class="followed" v-if="user.isFollowed">{{ $t('follows-you') }}</p>
<mk-follow-button :user="user" :inline="true" class="follow"/>
</template>
<ui-button @click="menu" ref="menu" :inline="true"><fa icon="ellipsis-h"/></ui-button>
</div>
<div class="description">
<mfm v-if="user.description" :text="user.description" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
</div>
<div class="fields" v-if="user.fields">
<dl class="field" v-for="(field, i) in user.fields" :key="i">
<dt class="name">
<mfm :text="field.name" :should-break="false" :plain-text="true" :custom-emojis="user.emojis"/>
</dt>
<dd class="value">
<mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/>
</dd>
</dl>
</div>
<div class="info">
<span class="location" v-if="user.host === null && user.profile.location"><fa icon="map-marker"/> {{ user.profile.location }}</span>
<span class="birthday" v-if="user.host === null && user.profile.birthday"><fa icon="birthday-cake"/> {{ user.profile.birthday.replace('-', $t('year')).replace('-', $t('month')) + $t('day') }} ({{ $t('years-old', { age }) }})</span>
</div>
<div class="status">
<span class="notes-count"><b>{{ user.notesCount | number }}</b>{{ $t('posts') }}</span>
<router-link :to="user | userPage('following')" class="following clickable"><b>{{ user.followingCount | number }}</b>{{ $t('following') }}</router-link>
<router-link :to="user | userPage('followers')" class="followers clickable"><b>{{ user.followersCount | number }}</b>{{ $t('followers') }}</router-link>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import * as age from 's-age';
import XUserMenu from '../../../../common/views/components/user-menu.vue';
export default Vue.extend({
i18n: i18n('desktop/views/pages/user/user.header.vue'),
props: ['user'],
computed: {
style(): any {
if (this.user.bannerUrl == null) return {};
return {
backgroundColor: this.user.bannerColor && this.user.bannerColor.length == 3 ? `rgb(${ this.user.bannerColor.join(',') })` : null,
backgroundImage: `url(${ this.user.bannerUrl })`
};
},
age(): number {
return age(this.user.profile.birthday);
}
},
mounted() {
if (this.user.bannerUrl) {
//window.addEventListener('load', this.onScroll);
//window.addEventListener('scroll', this.onScroll, { passive: true });
//window.addEventListener('resize', this.onScroll);
}
},
beforeDestroy() {
if (this.user.bannerUrl) {
//window.removeEventListener('load', this.onScroll);
//window.removeEventListener('scroll', this.onScroll);
//window.removeEventListener('resize', this.onScroll);
}
},
methods: {
mention() {
this.$post({ mention: this.user });
},
onScroll() {
const banner = this.$refs.banner as any;
const top = window.scrollY;
const z = 1.25; // ()
const pos = -(top / z);
banner.style.backgroundPosition = `center calc(50% - ${pos}px)`;
const blur = top / 32
if (blur <= 10) banner.style.filter = `blur(${blur}px)`;
},
onBannerClick() {
if (!this.$store.getters.isSignedIn || this.$store.state.i.id != this.user.id) return;
this.$updateBanner().then(i => {
this.user.bannerUrl = i.bannerUrl;
});
},
menu() {
this.$root.new(XUserMenu, {
source: this.$refs.menu.$el,
user: this.user
});
}
}
});
</script>
<style lang="stylus" scoped>
.header
background var(--face)
box-shadow var(--shadow)
border-radius var(--round)
overflow hidden
&[data-is-dark-background]
> .banner-container
> .banner
background-color #383838
> .fade
background linear-gradient(transparent, rgba(#000, 0.7))
> .title
color #fff
> .name
text-shadow 0 0 8px #000
> .banner-container
height 250px
overflow hidden
background-size cover
background-position center
> .banner
height 100%
background-color #bfccd0
background-size cover
background-position center
> .fade
position absolute
bottom 0
left 0
width 100%
height 78px
> .title
position absolute
bottom 0
left 0
width 100%
padding 0 0 8px 154px
color #5e6367
> .name
display block
margin 0
line-height 32px
font-weight bold
font-size 1.8em
> div
> *
display inline-block
margin-right 16px
line-height 20px
opacity 0.8
&.username
font-weight bold
> .avatar
display block
position absolute
top 170px
left 16px
z-index 2
width 120px
height 120px
box-shadow 1px 1px 3px rgba(#000, 0.2)
> &.cat::before,
> &.cat::after
border-width 8px
> .body
padding 16px 16px 16px 154px
color var(--text)
> .actions
> .follow
width 200px
> .fields
margin-top 16px
> .field
display flex
padding 0
margin 0
align-items center
> .name
border-right solid 1px var(--faceDivider)
padding 4px
margin 4px
width 30%
overflow hidden
white-space nowrap
text-overflow ellipsis
font-weight bold
text-align center
> .value
padding 4px
margin 4px
width 70%
overflow hidden
white-space nowrap
text-overflow ellipsis
> .info
margin-top 16px
padding-top 16px
border-top solid 1px var(--faceDivider)
> *
margin-right 16px
> .status
margin-top 16px
padding-top 16px
border-top solid 1px var(--faceDivider)
font-size 80%
> *
display inline-block
padding-right 16px
margin-right 16px
color inherit
&:not(:last-child)
border-right solid 1px var(--faceDivider)
&.clickable
cursor pointer
&:hover
color var(--faceTextButtonHover)
> b
margin-right 4px
font-size 1rem
font-weight bold
color var(--primary)
</style>

View file

@ -0,0 +1,14 @@
<template>
<a :href="url" :class="service" target="_blank">
<fa :icon="icon" size="lg" fixed-width />
<div>{{ text }}</div>
</a>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['url', 'text', 'icon', 'service']
});
</script>

View file

@ -0,0 +1,63 @@
<template>
<div class="usertwitxxxgithxxdiscxxxintegrat" :v-if="user.twitter || user.github || user.discord">
<x-integration v-if="user.twitter" service="twitter" :url="`https://twitter.com/${user.twitter.screenName}`" :text="user.twitter.screenName" :icon="['fab', 'twitter']"/>
<x-integration v-if="user.github" service="github" :url="`https://github.com/${user.github.login}`" :text="user.github.login" :icon="['fab', 'github']"/>
<x-integration v-if="user.discord" service="discord" :url="`https://discordapp.com/users/${user.discord.id}`" :text="`${user.discord.username}#${user.discord.discriminator}`" :icon="['fab', 'discord']"/>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import XIntegration from './user.integrations.integration.vue';
export default Vue.extend({
components: {
XIntegration
},
props: ['user']
});
</script>
<style lang="stylus" scoped>
.usertwitxxxgithxxdiscxxxintegrat
> a
display flex
align-items center
padding 32px 38px
box-shadow var(--shadow)
border-radius var(--round)
&:not(:last-child)
margin-bottom 16px
&:hover
text-decoration none
> div
padding-left .2em
line-height 1.3em
flex 1 0
word-wrap anywhere
&.twitter
color #fff
background #1da1f3
&:hover
background #0c87cf
&.github
color #fff
background #171515
&:hover
background #000
&.discord
color #fff
background #7289da
&:hover
background #4968ce
</style>

View file

@ -0,0 +1,106 @@
<template>
<div class="dzsuvbsrrrwobdxifudxuefculdfiaxd">
<p class="title"><fa icon="camera"/>{{ $t('title') }}</p>
<p class="initializing" v-if="fetching"><fa icon="spinner" pulse fixed-width/>{{ $t('loading') }}<mk-ellipsis/></p>
<div class="stream" v-if="!fetching && images.length > 0">
<div v-for="(image, i) in images" :key="i" class="img"
:style="`background-image: url(${thumbnail(image)})`"
></div>
</div>
<p class="empty" v-if="!fetching && images.length == 0">{{ $t('no-photos') }}</p>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import { getStaticImageUrl } from '../../../../common/scripts/get-static-image-url';
export default Vue.extend({
i18n: i18n('desktop/views/pages/user/user.photos.vue'),
props: ['user'],
data() {
return {
images: [],
fetching: true
};
},
mounted() {
const image = [
'image/jpeg',
'image/png',
'image/gif'
];
this.$root.api('users/notes', {
userId: this.user.id,
fileType: image,
excludeNsfw: !this.$store.state.device.alwaysShowNsfw,
limit: 9,
untilDate: new Date().getTime() + 1000 * 86400 * 365
}).then(notes => {
for (const note of notes) {
for (const file of note.files) {
if (this.images.length < 9) this.images.push(file);
}
}
this.fetching = false;
});
},
methods: {
thumbnail(image: any): string {
return this.$store.state.device.disableShowingAnimatedImages
? getStaticImageUrl(image.thumbnailUrl)
: image.thumbnailUrl;
},
},
});
</script>
<style lang="stylus" scoped>
.dzsuvbsrrrwobdxifudxuefculdfiaxd
background var(--face)
box-shadow var(--shadow)
border-radius var(--round)
overflow hidden
> .title
z-index 1
margin 0
padding 0 16px
line-height 42px
font-size 0.9em
font-weight bold
background var(--faceHeader)
color var(--faceHeaderText)
box-shadow 0 1px rgba(#000, 0.07)
> i
margin-right 4px
> .stream
display flex
justify-content center
flex-wrap wrap
padding 8px
> .img
flex 1 1 33%
width 33%
height 120px
background-position center center
background-size cover
background-clip content-box
border solid 2px transparent
> .initializing
> .empty
margin 0
padding 16px
text-align center
color var(--text)
> i
margin-right 4px
</style>

View file

@ -0,0 +1,175 @@
<template>
<div class="oh5y2r7l5lx8j6jj791ykeiwgihheguk">
<header>
<span :data-active="mode == 'default'" @click="mode = 'default'"><fa :icon="['far', 'comment-alt']"/> {{ $t('default') }}</span>
<span :data-active="mode == 'with-replies'" @click="mode = 'with-replies'"><fa icon="comments"/> {{ $t('with-replies') }}</span>
<span :data-active="mode == 'with-media'" @click="mode = 'with-media'"><fa :icon="['far', 'images']"/> {{ $t('with-media') }}</span>
<span :data-active="mode == 'my-posts'" @click="mode = 'my-posts'"><fa icon="user"/> {{ $t('my-posts') }}</span>
</header>
<mk-notes ref="timeline" :more="existMore ? more : null">
<p class="empty" slot="empty"><fa :icon="['far', 'comments']"/>{{ $t('empty') }}</p>
</mk-notes>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
const fetchLimit = 10;
export default Vue.extend({
i18n: i18n('desktop/views/pages/user/user.timeline.vue'),
props: ['user'],
data() {
return {
fetching: true,
moreFetching: false,
existMore: false,
mode: 'default',
unreadCount: 0,
date: null
};
},
watch: {
mode() {
this.fetch();
}
},
mounted() {
document.addEventListener('keydown', this.onDocumentKeydown);
this.fetch(() => this.$emit('loaded'));
},
beforeDestroy() {
document.removeEventListener('keydown', this.onDocumentKeydown);
},
methods: {
onDocumentKeydown(e) {
if (e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
if (e.which == 84) { // [t]
(this.$refs.timeline as any).focus();
}
}
},
fetch(cb?) {
this.fetching = true;
(this.$refs.timeline as any).init(() => new Promise((res, rej) => {
this.$root.api('users/notes', {
userId: this.user.id,
limit: fetchLimit + 1,
untilDate: this.date ? this.date.getTime() : new Date().getTime() + 1000 * 86400 * 365,
includeReplies: this.mode == 'with-replies',
includeMyRenotes: this.mode != 'my-posts',
withFiles: this.mode == 'with-media'
}).then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
this.existMore = true;
}
res(notes);
this.fetching = false;
if (cb) cb();
}, rej);
}));
},
more() {
this.moreFetching = true;
const promise = this.$root.api('users/notes', {
userId: this.user.id,
limit: fetchLimit + 1,
includeReplies: this.mode == 'with-replies',
includeMyRenotes: this.mode != 'my-posts',
withFiles: this.mode == 'with-media',
untilDate: new Date((this.$refs.timeline as any).tail().createdAt).getTime()
});
promise.then(notes => {
if (notes.length == fetchLimit + 1) {
notes.pop();
} else {
this.existMore = false;
}
for (const n of notes) {
(this.$refs.timeline as any).append(n);
}
this.moreFetching = false;
});
return promise;
},
warp(date) {
this.date = date;
this.fetch();
}
}
});
</script>
<style lang="stylus" scoped>
.oh5y2r7l5lx8j6jj791ykeiwgihheguk
background var(--face)
border-radius var(--round)
overflow hidden
> header
padding 0 8px
z-index 10
background var(--faceHeader)
box-shadow 0 1px var(--desktopTimelineHeaderShadow)
> span
display inline-block
padding 0 10px
line-height 42px
font-size 12px
user-select none
&[data-active]
color var(--primary)
cursor default
font-weight bold
&:before
content ""
display block
position absolute
bottom 0
left -8px
width calc(100% + 16px)
height 2px
background var(--primary)
&:not([data-active])
color var(--desktopTimelineSrc)
cursor pointer
&:hover
color var(--desktopTimelineSrcHover)
> .mk-notes
> .empty
display block
margin 0 auto
padding 32px
max-width 400px
text-align center
color var(--text)
> [data-icon]
display block
margin-bottom 16px
font-size 3em
color var(--faceHeaderText);
</style>

View file

@ -0,0 +1,109 @@
<template>
<div class="xygkxeaeontfaokvqmiblezmhvhostak" v-if="!fetching">
<div class="is-suspended" v-if="user.isSuspended"><fa icon="exclamation-triangle"/> {{ $t('@.user-suspended') }}</div>
<div class="is-remote" v-if="user.host != null"><fa icon="exclamation-triangle"/> {{ $t('@.is-remote-user') }}<a :href="user.url || user.uri" target="_blank">{{ $t('@.view-on-remote') }}</a></div>
<div class="main">
<x-header :user="user"/>
<mk-note-detail v-for="n in user.pinnedNotes" :key="n.id" :note="n" :compact="true"/>
<x-integrations :user="user"/>
<!--<mk-calendar @chosen="warp" :start="new Date(user.createdAt)"/>-->
<div class="activity">
<mk-widget-container :show-header="true" :naked="false">
<template slot="header"><fa icon="chart-bar"/>{{ $t('activity') }}</template>
<x-activity :user="user" :limit="35" style="padding: 16px;"/>
</mk-widget-container>
</div>
<x-photos :user="user"/>
<x-friends :user="user"/>
<x-followers-you-know v-if="$store.getters.isSignedIn && $store.state.i.id != user.id" :user="user"/>
<x-timeline class="timeline" ref="tl" :user="user"/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../../../../i18n';
import parseAcct from '../../../../../../misc/acct/parse';
import Progress from '../../../../common/scripts/loading';
import XHeader from './user.header.vue';
import XTimeline from './user.timeline.vue';
import XPhotos from './user.photos.vue';
import XFollowersYouKnow from './user.followers-you-know.vue';
import XFriends from './user.friends.vue';
import XIntegrations from './user.integrations.vue';
import XActivity from '../../../../common/views/components/activity.vue';
export default Vue.extend({
i18n: i18n(),
components: {
XHeader,
XTimeline,
XPhotos,
XFollowersYouKnow,
XFriends,
XIntegrations,
XActivity
},
data() {
return {
fetching: true,
user: null
};
},
watch: {
$route: 'fetch'
},
created() {
this.fetch();
},
methods: {
fetch() {
this.fetching = true;
Progress.start();
this.$root.api('users/show', parseAcct(this.$route.params.user)).then(user => {
this.user = user;
this.fetching = false;
Progress.done();
});
},
warp(date) {
(this.$refs.tl as any).warp(date);
}
}
});
</script>
<style lang="stylus" scoped>
.xygkxeaeontfaokvqmiblezmhvhostak
width 100%
margin 0 auto
> .is-suspended
> .is-remote
margin-bottom 16px
padding 14px 16px
font-size 14px
box-shadow var(--shadow)
border-radius var(--round)
&.is-suspended
color var(--suspendedInfoFg)
background var(--suspendedInfoBg)
&.is-remote
color var(--remoteInfoFg)
background var(--remoteInfoBg)
> a
font-weight bold
> .main
> *
margin-bottom 16px
> .timeline
box-shadow var(--shadow)
</style>