2024.5.0-mattyatea4
This commit is contained in:
parent
58ce5a5f79
commit
564f2e9127
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "misskey",
|
"name": "misskey",
|
||||||
"version": "2024.5.0-mattyatea3",
|
"version": "2024.5.0-mattyatea4",
|
||||||
"codename": "nasubi",
|
"codename": "nasubi",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
@ -1010,6 +1010,9 @@ export class NoteCreateService implements OnApplicationShutdown {
|
||||||
this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r);
|
this.fanoutTimelineService.push('localTimelineWithFiles', note.id, 500, r);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (note.visibility === 'public' && note.userHost !== null) {
|
||||||
|
this.fanoutTimelineService.push(`remoteLocalTimeline:${note.userHost}`, note.id, 1000, r);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Math.random() < 0.1) {
|
if (Math.random() < 0.1) {
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { In } from 'typeorm';
|
import { Brackets, In } from 'typeorm';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||||
|
@ -24,6 +24,8 @@ import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
||||||
|
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||||
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
|
@ -80,168 +82,81 @@ export const paramDef = {
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
constructor(
|
constructor(
|
||||||
|
private idService: IdService,
|
||||||
|
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||||
|
private queryService: QueryService,
|
||||||
@Inject(DI.notesRepository)
|
@Inject(DI.notesRepository)
|
||||||
private notesRepository: NotesRepository,
|
private notesRepository: NotesRepository,
|
||||||
private idService: IdService,
|
|
||||||
private federatedInstanceService: FederatedInstanceService,
|
|
||||||
private httpRequestService: HttpRequestService,
|
|
||||||
private utilityService: UtilityService,
|
|
||||||
private userEntityService: UserEntityService,
|
|
||||||
private noteEntityService: NoteEntityService,
|
|
||||||
private metaService: MetaService,
|
|
||||||
private apResolverService: ApResolverService,
|
|
||||||
private apDbResolverService: ApDbResolverService,
|
|
||||||
private apPersonService: ApPersonService,
|
|
||||||
private apNoteService: ApNoteService,
|
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||||
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
|
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
|
||||||
if (ps.host === undefined) throw new ApiError(meta.errors.hostIsNull);
|
|
||||||
if (ps.remoteToken === undefined) throw new ApiError(meta.errors.remoteTokenIsNull);
|
|
||||||
const i = await this.federatedInstanceService.fetch(ps.host);
|
|
||||||
const noteIds = [];
|
|
||||||
|
|
||||||
if (i.softwareName === 'misskey') {
|
if (ps.withReplies && ps.withFiles) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles);
|
||||||
const remoteTimeline: string[] = await (await this.httpRequestService.send('https://' + ps.host + '/api/notes/local-timeline', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
i: ps.remoteToken,
|
|
||||||
withFiles: ps.withFiles,
|
|
||||||
withRenotes: ps.withRenotes,
|
|
||||||
withReplies: ps.withReplies,
|
|
||||||
limit: 30,
|
|
||||||
}),
|
|
||||||
})).json() as string[];
|
|
||||||
|
|
||||||
if (remoteTimeline.length > 0) {
|
const timeline = await this.fanoutTimelineEndpointService.timeline({
|
||||||
for (const note of remoteTimeline) {
|
untilId,
|
||||||
const uri = `https://${ps.host}/notes/${note.id}`;
|
sinceId,
|
||||||
const note_ = await this.fetchAny(uri, me);
|
limit: ps.limit,
|
||||||
if (note_ == null) continue;
|
allowPartial: ps.allowPartial,
|
||||||
noteIds.push(note_.id);
|
me,
|
||||||
}
|
useDbFallback: true,
|
||||||
}
|
redisTimelines: [`remoteLocalTimeline:${ps.host}`],
|
||||||
|
alwaysIncludeMyNotes: true,
|
||||||
|
excludePureRenotes: !ps.withRenotes,
|
||||||
|
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
|
||||||
|
untilId,
|
||||||
|
sinceId,
|
||||||
|
limit,
|
||||||
|
withFiles: ps.withFiles,
|
||||||
|
withReplies: ps.withReplies,
|
||||||
|
host: ps.host,
|
||||||
|
}, me),
|
||||||
|
});
|
||||||
|
|
||||||
let notes = await this.notesRepository.findBy({ id: In(noteIds) });
|
return timeline;
|
||||||
let packedNote: any[] = await this.noteEntityService.packMany(notes, me, { detail: true });
|
},
|
||||||
if (untilId) {
|
|
||||||
let lastRemoteId;
|
|
||||||
const lastUri = packedNote[packedNote.length - 1].uri;
|
|
||||||
lastRemoteId = lastUri.split('/')[lastUri.split('/').length - 1];
|
|
||||||
do {
|
|
||||||
const remoteTimeline: string[] = await (await this.httpRequestService.send('https://' + ps.host + '/api/notes/local-timeline', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
i: ps.remoteToken,
|
|
||||||
withFiles: ps.withFiles,
|
|
||||||
withRenotes: ps.withRenotes,
|
|
||||||
withReplies: ps.withReplies,
|
|
||||||
untilId: lastRemoteId,
|
|
||||||
limit: 30,
|
|
||||||
}),
|
|
||||||
})).json() as string[];
|
|
||||||
|
|
||||||
if (remoteTimeline.length > 0) {
|
|
||||||
for (const note of remoteTimeline) {
|
|
||||||
const uri = `https://${ps.host}/notes/${note.id}`;
|
|
||||||
const note_ = await this.fetchAny(uri, me);
|
|
||||||
if (note_ == null) continue;
|
|
||||||
//noteIds.push(note_.id);
|
|
||||||
lastRemoteId = note_.id;
|
|
||||||
if (lastRemoteId === ps.untilId) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} while (lastRemoteId !== ps.untilId);
|
|
||||||
const remoteTimeline: string[] = await (await this.httpRequestService.send('https://' + ps.host + '/api/notes/local-timeline', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
i: ps.remoteToken,
|
|
||||||
withFiles: ps.withFiles,
|
|
||||||
withRenotes: ps.withRenotes,
|
|
||||||
withReplies: ps.withReplies,
|
|
||||||
untilId: lastRemoteId,
|
|
||||||
limit: 30,
|
|
||||||
}),
|
|
||||||
})).json() as string[];
|
|
||||||
|
|
||||||
if (remoteTimeline.length > 0) {
|
|
||||||
for (const note of remoteTimeline) {
|
|
||||||
const uri = `https://${ps.host}/notes/${note.id}`;
|
|
||||||
const note_ = await this.fetchAny(uri, me);
|
|
||||||
if (note_ == null) continue;
|
|
||||||
noteIds.push(note_.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notes = await this.notesRepository.findBy({ id: In(noteIds) });
|
|
||||||
packedNote = await this.noteEntityService.packMany(notes, me, { detail: true });
|
|
||||||
return packedNote.reverse();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@bindThis
|
|
||||||
private async fetchAny(uri: string, me: MiLocalUser | null | undefined) {
|
|
||||||
// ブロックしてたら中断
|
|
||||||
const fetchedMeta = await this.metaService.fetch();
|
|
||||||
if (this.utilityService.isBlockedHost(fetchedMeta.blockedHosts, this.utilityService.extractDbHost(uri))) return null;
|
|
||||||
|
|
||||||
let local = await this.mergePack(me, ...await Promise.all([
|
|
||||||
this.apDbResolverService.getUserFromApId(uri),
|
|
||||||
this.apDbResolverService.getNoteFromApId(uri),
|
|
||||||
]));
|
|
||||||
if (local != null) return local;
|
|
||||||
|
|
||||||
// リモートから一旦オブジェクトフェッチ
|
|
||||||
let object;
|
|
||||||
try {
|
|
||||||
const resolver = this.apResolverService.createResolver();
|
|
||||||
object = await resolver.resolve(uri) as any;
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!object) return null;
|
|
||||||
// /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する
|
|
||||||
// これはDBに存在する可能性があるため再度DB検索
|
|
||||||
if (uri !== object.id) {
|
|
||||||
local = await this.mergePack(me, ...await Promise.all([
|
|
||||||
this.apDbResolverService.getUserFromApId(object.id),
|
|
||||||
this.apDbResolverService.getNoteFromApId(object.id),
|
|
||||||
]));
|
|
||||||
if (local != null) return local;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await this.mergePack(
|
|
||||||
me,
|
|
||||||
isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null,
|
|
||||||
isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, true) : null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
private async getFromDb(ps: {
|
||||||
|
sinceId: string | null,
|
||||||
|
untilId: string | null,
|
||||||
|
limit: number,
|
||||||
|
withFiles: boolean,
|
||||||
|
withReplies: boolean,
|
||||||
|
host: string,
|
||||||
|
}, me: MiLocalUser | null) {
|
||||||
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
||||||
|
ps.sinceId, ps.untilId)
|
||||||
|
.andWhere(`(note.visibility = \'public\') AND (note.userHost = \'${ps.host}\') AND (note.channelId IS NULL)`)
|
||||||
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
|
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||||
|
|
||||||
@bindThis
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
private async mergePack(me: MiLocalUser | null | undefined, user: MiUser | null | undefined, note: MiNote | null | undefined) {
|
if (me) this.queryService.generateMutedUserQuery(query, me);
|
||||||
if (note != null) {
|
if (me) this.queryService.generateBlockedUserQuery(query, me);
|
||||||
try {
|
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||||
const object = await this.noteEntityService.pack(note, me, { detail: true });
|
|
||||||
|
|
||||||
return object;
|
if (ps.withFiles) {
|
||||||
} catch (e) {
|
query.andWhere('note.fileIds != \'{}\'');
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
if (!ps.withReplies) {
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb
|
||||||
|
.where('note.replyId IS NULL') // 返信ではない
|
||||||
|
.orWhere(new Brackets(qb => {
|
||||||
|
qb // 返信だけど投稿者自身への返信
|
||||||
|
.where('note.replyId IS NOT NULL')
|
||||||
|
.andWhere('note.replyUserId = note.userId');
|
||||||
|
}));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query.limit(ps.limit).getMany();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,6 +28,7 @@ export async function mainBoot() {
|
||||||
!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
|
!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
|
||||||
ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
|
ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
|
||||||
ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) :
|
ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) :
|
||||||
|
ui === 'twilike' ? defineAsyncComponent(() => import('@/ui/twilike.vue')) :
|
||||||
defineAsyncComponent(() => import('@/ui/universal.vue')),
|
defineAsyncComponent(() => import('@/ui/universal.vue')),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
|
@ -165,14 +165,12 @@ function updatePaginationQuery() {
|
||||||
channel: { channelId: props.channel },
|
channel: { channelId: props.channel },
|
||||||
role: { roleId: props.role },
|
role: { roleId: props.role },
|
||||||
};
|
};
|
||||||
|
if (props.src.startsWith('remoteLocalTimeline')) {
|
||||||
if (props.src.startsWith('custom-timeline')) {
|
|
||||||
paginationQuery = {
|
paginationQuery = {
|
||||||
endpoint: 'notes/any-local-timeline',
|
endpoint: 'notes/any-local-timeline',
|
||||||
limit: 10,
|
limit: 10,
|
||||||
params: {
|
params: {
|
||||||
host: defaultStore.state[`remoteLocalTimelineDomain${props.src.split('-')[2]}`],
|
host: props.list,
|
||||||
remoteToken: defaultStore.state[`remoteLocalTimelineToken${props.src.split('-')[2]}`],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
|
1075
packages/frontend/src/components/XNote.vue
Normal file
1075
packages/frontend/src/components/XNote.vue
Normal file
File diff suppressed because it is too large
Load diff
137
packages/frontend/src/components/XNoteHeader.vue
Normal file
137
packages/frontend/src/components/XNoteHeader.vue
Normal file
|
@ -0,0 +1,137 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header :class="$style.root">
|
||||||
|
<div v-if="mock" :class="$style.name">
|
||||||
|
<MkUserName :user="note.user"/>
|
||||||
|
</div>
|
||||||
|
<MkA v-else v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)">
|
||||||
|
<MkUserName :user="note.user"/>
|
||||||
|
</MkA>
|
||||||
|
<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
|
||||||
|
<div :class="$style.username"><MkAcct :user="note.user"/></div>
|
||||||
|
<div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
|
||||||
|
<img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
|
||||||
|
</div>
|
||||||
|
<div v-if="mock">
|
||||||
|
<MkTime :time="note.createdAt" colored/>
|
||||||
|
</div>
|
||||||
|
<MkA v-else :class="$style.time" :to="notePage(note)">
|
||||||
|
<MkTime :time="note.createdAt" colored/>
|
||||||
|
</MkA>
|
||||||
|
<div :class="$style.info">
|
||||||
|
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="showMenu()">
|
||||||
|
<i class="ti ti-dots"></i>
|
||||||
|
</button>
|
||||||
|
<span v-if="note.visibility !== 'public'" style="margin-left: 0.5em;" :title="i18n.ts._visibility[note.visibility]">
|
||||||
|
<i v-if="note.visibility === 'home'" class="ti ti-home"></i>
|
||||||
|
<i v-else-if="note.visibility === 'followers'" class="ti ti-lock"></i>
|
||||||
|
<i v-else-if="note.visibility === 'specified'" ref="specified" class="ti ti-mail"></i>
|
||||||
|
</span>
|
||||||
|
<span v-if="note.localOnly" style="margin-left: 0.5em;" :title="i18n.ts._visibility['disableFederation']"><i class="ti ti-rocket-off"></i></span>
|
||||||
|
<span v-if="note.channel" style="margin-left: 0.5em;" :title="note.channel.name"><i class="ti ti-device-tv"></i></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { inject, shallowRef } from 'vue';
|
||||||
|
import * as Misskey from 'misskey-js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import { notePage } from '@/filters/note.js';
|
||||||
|
import { userPage } from '@/filters/user.js';
|
||||||
|
import { getNoteMenu } from '@/scripts/get-note-menu.js';
|
||||||
|
import * as os from '@/os.js';
|
||||||
|
const menuButton = shallowRef<HTMLElement>();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
note: Misskey.entities.Note;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function showMenu(viaKeyboard = false): void {
|
||||||
|
if (mock) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { menu, cleanup } = getNoteMenu({ note: props.note });
|
||||||
|
os.popupMenu(menu, menuButton.value, {
|
||||||
|
viaKeyboard,
|
||||||
|
}).then(focus).finally(cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mock = inject<boolean>('mock', false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.root {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.footerButton {
|
||||||
|
margin: -12px 0 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--fgHighlighted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
flex-shrink: 1;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.isBot {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
margin: 0 .5em 0 0;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 80%;
|
||||||
|
border: solid 0.5px var(--divider);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
flex-shrink: 9999999;
|
||||||
|
margin: 0 .5em 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--fgTransparentWeak);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeRoles {
|
||||||
|
margin: 0 .5em 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badgeRole {
|
||||||
|
height: 1.3em;
|
||||||
|
vertical-align: -20%;
|
||||||
|
|
||||||
|
& + .badgeRole {
|
||||||
|
margin-left: 0.2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.time{
|
||||||
|
color: var(--fgTransparentWeak);
|
||||||
|
}
|
||||||
|
</style>
|
1349
packages/frontend/src/components/XPostForm.vue
Normal file
1349
packages/frontend/src/components/XPostForm.vue
Normal file
File diff suppressed because it is too large
Load diff
62
packages/frontend/src/components/XPostFormDialog.vue
Normal file
62
packages/frontend/src/components/XPostFormDialog.vue
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MkModal ref="modal" :preferType="'dialog'" @click="modal?.close()" @closed="onModalClosed()">
|
||||||
|
<XPostForm ref="form" :class="$style.form" :dialog="true" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal?.close()" @esc="modal?.close()"/>
|
||||||
|
</MkModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { shallowRef } from 'vue';
|
||||||
|
import * as Misskey from 'misskey-js';
|
||||||
|
import MkModal from '@/components/MkModal.vue';
|
||||||
|
import MkPostForm from '@/components/MkPostForm.vue';
|
||||||
|
import XPostForm from '@/components/XPostForm.vue';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
reply?: Misskey.entities.Note;
|
||||||
|
renote?: Misskey.entities.Note;
|
||||||
|
channel?: any; // TODO
|
||||||
|
mention?: Misskey.entities.User;
|
||||||
|
specified?: Misskey.entities.UserDetailed;
|
||||||
|
initialText?: string;
|
||||||
|
initialCw?: string;
|
||||||
|
initialVisibility?: (typeof Misskey.noteVisibilities)[number];
|
||||||
|
initialFiles?: Misskey.entities.DriveFile[];
|
||||||
|
initialLocalOnly?: boolean;
|
||||||
|
initialVisibleUsers?: Misskey.entities.UserDetailed[];
|
||||||
|
initialNote?: Misskey.entities.Note;
|
||||||
|
instant?: boolean;
|
||||||
|
fixed?: boolean;
|
||||||
|
autofocus?: boolean;
|
||||||
|
}>(), {
|
||||||
|
initialLocalOnly: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(ev: 'closed'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||||
|
const form = shallowRef<InstanceType<typeof MkPostForm>>();
|
||||||
|
|
||||||
|
function onPosted() {
|
||||||
|
modal.value?.close({
|
||||||
|
useSendAnimation: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onModalClosed() {
|
||||||
|
emit('closed');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.form {
|
||||||
|
max-height: 100%;
|
||||||
|
margin: 0 auto auto auto;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -5,10 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="el" :class="$style.tabs" @wheel="onTabWheel">
|
<div ref="el" :class="$style.tabs" @wheel="onTabWheel">
|
||||||
<div :class="$style.tabsInner">
|
<div :class="ui !== 'twilike' ? $style.tabsInner : $style.tabsInnerX">
|
||||||
<button
|
<button
|
||||||
v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title"
|
v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title"
|
||||||
class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: defaultStore.reactiveState.animation.value }]"
|
class="_button" :class="[ui !== 'twilike' ? $style.tab : $style.tabX, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: defaultStore.reactiveState.animation.value }]"
|
||||||
@mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)"
|
@mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)"
|
||||||
>
|
>
|
||||||
<div :class="$style.tabInner">
|
<div :class="$style.tabInner">
|
||||||
|
@ -55,6 +55,7 @@ export type Tab = {
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, onUnmounted, watch, nextTick, shallowRef, ref, computed } from 'vue';
|
import { onMounted, onUnmounted, watch, nextTick, shallowRef, ref, computed } from 'vue';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
|
import { ui } from '@/config.js';
|
||||||
|
|
||||||
const gamingType = computed(defaultStore.makeGetterSetter('gamingType'));
|
const gamingType = computed(defaultStore.makeGetterSetter('gamingType'));
|
||||||
|
|
||||||
|
@ -207,6 +208,15 @@ onUnmounted(() => {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tabsInnerX {
|
||||||
|
display: flex;
|
||||||
|
height: var(--height);
|
||||||
|
white-space: nowrap;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
.tabX{
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
.tab {
|
.tab {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -231,6 +241,7 @@ onUnmounted(() => {
|
||||||
.tabInner {
|
.tabInner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabIcon + .tabTitle {
|
.tabIcon + .tabTitle {
|
||||||
|
|
|
@ -150,8 +150,8 @@ onUnmounted(() => {
|
||||||
height: var(--height);
|
height: var(--height);
|
||||||
|
|
||||||
.tabs:first-child {
|
.tabs:first-child {
|
||||||
margin-left: auto;
|
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.tabs {
|
.tabs {
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
|
@ -168,6 +168,7 @@ onUnmounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
&.slim {
|
&.slim {
|
||||||
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
.buttonsRight {
|
.buttonsRight {
|
||||||
|
|
|
@ -147,6 +147,13 @@ export const navbarItemDef = reactive({
|
||||||
miLocalStorage.setItem('ui', 'classic');
|
miLocalStorage.setItem('ui', 'classic');
|
||||||
unisonReload();
|
unisonReload();
|
||||||
},
|
},
|
||||||
|
}, {
|
||||||
|
text: 'twilike',
|
||||||
|
active: ui === 'twilike',
|
||||||
|
action: () => {
|
||||||
|
miLocalStorage.setItem('ui', 'twilike');
|
||||||
|
unisonReload();
|
||||||
|
},
|
||||||
}], ev.currentTarget ?? ev.target);
|
}], ev.currentTarget ?? ev.target);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -13,6 +13,7 @@ import type { Form, GetFormResultType } from '@/scripts/form.js';
|
||||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import MkPostFormDialog from '@/components/MkPostFormDialog.vue';
|
import MkPostFormDialog from '@/components/MkPostFormDialog.vue';
|
||||||
|
import XPostFormDialog from '@/components/XPostFormDialog.vue';
|
||||||
import MkWaitingDialog from '@/components/MkWaitingDialog.vue';
|
import MkWaitingDialog from '@/components/MkWaitingDialog.vue';
|
||||||
import MkPageWindow from '@/components/MkPageWindow.vue';
|
import MkPageWindow from '@/components/MkPageWindow.vue';
|
||||||
import MkToast from '@/components/MkToast.vue';
|
import MkToast from '@/components/MkToast.vue';
|
||||||
|
@ -25,6 +26,7 @@ import { MenuItem } from '@/types/menu.js';
|
||||||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||||
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
|
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
|
||||||
import MkSwitch from '@/components/MkSwitch.vue';
|
import MkSwitch from '@/components/MkSwitch.vue';
|
||||||
|
import { ui } from '@/config.js';
|
||||||
|
|
||||||
export const openingWindowsCount = ref(0);
|
export const openingWindowsCount = ref(0);
|
||||||
|
|
||||||
|
@ -551,7 +553,7 @@ export async function selectUser(opts: { includeSelf?: boolean; localOnly?: bool
|
||||||
popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {
|
popup(defineAsyncComponent(() => import('@/components/MkUserSelectDialog.vue')), {
|
||||||
includeSelf: opts.includeSelf,
|
includeSelf: opts.includeSelf,
|
||||||
localOnly: opts.localOnly,
|
localOnly: opts.localOnly,
|
||||||
multiple: opts.multiple,
|
multiple: opts.multiple,
|
||||||
}, {
|
}, {
|
||||||
ok: user => {
|
ok: user => {
|
||||||
resolve(user);
|
resolve(user);
|
||||||
|
@ -682,14 +684,25 @@ export function post(props: Record<string, any> = {}): Promise<void> {
|
||||||
// 複数のpost formを開いたときに場合によってはエラーになる
|
// 複数のpost formを開いたときに場合によってはエラーになる
|
||||||
// もちろん複数のpost formを開けること自体Misskeyサイドのバグなのだが
|
// もちろん複数のpost formを開けること自体Misskeyサイドのバグなのだが
|
||||||
let dispose;
|
let dispose;
|
||||||
popup(MkPostFormDialog, props, {
|
if (ui !== 'twilike') {
|
||||||
closed: () => {
|
popup(MkPostFormDialog, props, {
|
||||||
resolve();
|
closed: () => {
|
||||||
dispose();
|
resolve();
|
||||||
},
|
dispose();
|
||||||
}).then(res => {
|
},
|
||||||
dispose = res.dispose;
|
}).then(res => {
|
||||||
});
|
dispose = res.dispose;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
popup(XPostFormDialog, props, {
|
||||||
|
closed: () => {
|
||||||
|
resolve();
|
||||||
|
dispose();
|
||||||
|
},
|
||||||
|
}).then(res => {
|
||||||
|
dispose = res.dispose;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -117,7 +117,7 @@ const menuDef = computed(() => [{
|
||||||
}, {
|
}, {
|
||||||
icon: 'ti ti-layout-navbar',
|
icon: 'ti ti-layout-navbar',
|
||||||
text: i18n.ts.timelineHeader,
|
text: i18n.ts.timelineHeader,
|
||||||
to: '/settings/timelineheader',
|
to: '/settings/timeline-header',
|
||||||
active: currentPage.value?.route.name === 'timelineHeader',
|
active: currentPage.value?.route.name === 'timelineHeader',
|
||||||
}, {
|
}, {
|
||||||
icon: 'ti ti-equal-double',
|
icon: 'ti ti-equal-double',
|
||||||
|
|
|
@ -30,6 +30,21 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
</MkContainer>
|
</MkContainer>
|
||||||
</FormSlot>
|
</FormSlot>
|
||||||
|
<MkFoldableSection>
|
||||||
|
<template #header>リモートのローカルタイムライン</template>
|
||||||
|
|
||||||
|
<div v-if="remoteLocalTimeline.length < 3">
|
||||||
|
<MkInput v-model="tmpName" placeholder="remoteLocalTimeline 1"/>
|
||||||
|
<MkInput v-model="tmpServer" placeholder="https://prismisskey.space"/>
|
||||||
|
<MkButton @click="addRemote"><i class="ti ti-plus"></i> {{ i18n.ts.addItem }}</MkButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(a,i) in remoteLocalTimeline" :key="i">
|
||||||
|
<MkInput v-model="remoteLocalTimeline[i]['name']" :placeholder="a"/>
|
||||||
|
<MkInput v-model="remoteLocalTimeline[i]['host']" :placeholder="a"/>
|
||||||
|
<MkButton danger @click="deleteRemote(i)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||||
|
</div>
|
||||||
|
</MkFoldableSection>
|
||||||
<div class="_buttons">
|
<div class="_buttons">
|
||||||
<MkButton @click="addItem"><i class="ti ti-plus"></i> {{ i18n.ts.addItem }}</MkButton>
|
<MkButton @click="addItem"><i class="ti ti-plus"></i> {{ i18n.ts.addItem }}</MkButton>
|
||||||
<MkButton danger @click="reset"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
|
<MkButton danger @click="reset"><i class="ti ti-reload"></i> {{ i18n.ts.default }}</MkButton>
|
||||||
|
@ -39,25 +54,30 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
import { computed, defineAsyncComponent, ref } from 'vue';
|
||||||
import MkRadios from '@/components/MkRadios.vue';
|
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import FormSlot from '@/components/form/slot.vue';
|
import FormSlot from '@/components/form/slot.vue';
|
||||||
import MkContainer from '@/components/MkContainer.vue';
|
import MkContainer from '@/components/MkContainer.vue';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { navbarItemDef } from '@/navbar.js';
|
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
import { unisonReload } from '@/scripts/unison-reload.js';
|
import { unisonReload } from '@/scripts/unison-reload.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
import { timelineHeaderItemDef } from '@/timeline-header.js';
|
import { timelineHeaderItemDef } from '@/timeline-header.js';
|
||||||
|
import MkInput from '@/components/MkInput.vue';
|
||||||
|
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||||
|
import { $i } from '@/account.js';
|
||||||
|
|
||||||
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
|
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
|
||||||
|
const tmpName = ref();
|
||||||
|
const tmpServer = ref();
|
||||||
|
|
||||||
const items = ref(defaultStore.state.timelineHeader.map(x => ({
|
const items = ref(defaultStore.state.timelineHeader.map(x => ({
|
||||||
id: Math.random().toString(),
|
id: Math.random().toString(),
|
||||||
type: x,
|
type: x,
|
||||||
})));
|
})));
|
||||||
|
const remoteLocalTimeline = ref(defaultStore.state.remoteLocalTimeline);
|
||||||
|
const maxLocalTimeline = $i.policies.localTimelineAnyLimit;
|
||||||
|
|
||||||
async function reloadAsk() {
|
async function reloadAsk() {
|
||||||
const { canceled } = await os.confirm({
|
const { canceled } = await os.confirm({
|
||||||
|
@ -69,11 +89,31 @@ async function reloadAsk() {
|
||||||
unisonReload();
|
unisonReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function addRemote() {
|
||||||
|
if (!tmpName.value || !tmpServer.value) return;
|
||||||
|
if (maxLocalTimeline <= remoteLocalTimeline.value.length) return;
|
||||||
|
remoteLocalTimeline.value.push({
|
||||||
|
id: Math.random().toString(),
|
||||||
|
name: tmpName.value,
|
||||||
|
host: tmpServer.value,
|
||||||
|
});
|
||||||
|
tmpName.value = '';
|
||||||
|
tmpServer.value = '';
|
||||||
|
await defaultStore.set('remoteLocalTimeline', remoteLocalTimeline.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const menu = computed(() => {
|
||||||
|
return Object.keys(timelineHeaderItemDef).filter(k => !items.value.map(item => item.type).includes(k));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deleteRemote(index: number) {
|
||||||
|
remoteLocalTimeline.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
async function addItem() {
|
async function addItem() {
|
||||||
const menu = Object.keys(timelineHeaderItemDef).filter(k => !defaultStore.state.timelineHeader.includes(k));
|
|
||||||
const { canceled, result: item } = await os.select({
|
const { canceled, result: item } = await os.select({
|
||||||
title: i18n.ts.addItem,
|
title: i18n.ts.addItem,
|
||||||
items: [...menu.map(k => ({
|
items: [...menu.value.map(k => ({
|
||||||
value: k, text: timelineHeaderItemDef[k].title,
|
value: k, text: timelineHeaderItemDef[k].title,
|
||||||
}))],
|
}))],
|
||||||
});
|
});
|
||||||
|
|
|
@ -12,7 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkInfo v-if="['home', 'local', 'social', 'global'].includes(src) && !defaultStore.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--margin);" closable @close="closeTutorial()">
|
<MkInfo v-if="['home', 'local', 'social', 'global'].includes(src) && !defaultStore.reactiveState.timelineTutorials.value[src]" style="margin-bottom: var(--margin);" closable @close="closeTutorial()">
|
||||||
{{ i18n.ts._timelineDescription[src] }}
|
{{ i18n.ts._timelineDescription[src] }}
|
||||||
</MkInfo>
|
</MkInfo>
|
||||||
<MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostForm.value" :channel="channelInfo" :autofocus="deviceKind === 'desktop'" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/>
|
<MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostForm.value && ui !== 'twilike'" :channel="channelInfo" :autofocus="deviceKind === 'desktop'" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/>
|
||||||
|
<XPostForm v-if="$i && ui === 'twilike' " :channel="channelInfo" :autofocus="deviceKind === 'desktop'" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/>
|
||||||
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
|
<div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" :class="$style.newButton" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div>
|
||||||
<div :class="$style.tl">
|
<div :class="$style.tl">
|
||||||
<MkTimeline
|
<MkTimeline
|
||||||
|
@ -21,6 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
:src="src.split(':')[0]"
|
:src="src.split(':')[0]"
|
||||||
:list="src.split(':')[1]"
|
:list="src.split(':')[1]"
|
||||||
:channel="src.split(':')[1]"
|
:channel="src.split(':')[1]"
|
||||||
|
:antenna="src.split(':')[1]"
|
||||||
:withRenotes="withRenotes"
|
:withRenotes="withRenotes"
|
||||||
:withReplies="withReplies"
|
:withReplies="withReplies"
|
||||||
:onlyFiles="onlyFiles"
|
:onlyFiles="onlyFiles"
|
||||||
|
@ -47,7 +49,6 @@ import * as os from '@/os.js';
|
||||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { instance } from '@/instance.js';
|
|
||||||
import { $i } from '@/account.js';
|
import { $i } from '@/account.js';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
import { antennasCache, userFavoriteListsCache, userListsCache, favoritedChannelsCache } from '@/cache.js';
|
import { antennasCache, userFavoriteListsCache, userListsCache, favoritedChannelsCache } from '@/cache.js';
|
||||||
|
@ -57,6 +58,8 @@ import { MenuItem } from '@/types/menu.js';
|
||||||
import { miLocalStorage } from '@/local-storage.js';
|
import { miLocalStorage } from '@/local-storage.js';
|
||||||
import { timelineHeaderItemDef } from '@/timeline-header.js';
|
import { timelineHeaderItemDef } from '@/timeline-header.js';
|
||||||
import { isLocalTimelineAvailable, isGlobalTimelineAvailable } from '@/scripts/get-timeline-available.js';
|
import { isLocalTimelineAvailable, isGlobalTimelineAvailable } from '@/scripts/get-timeline-available.js';
|
||||||
|
import { ui } from '@/config.js';
|
||||||
|
import XPostForm from '@/components/XPostForm.vue';
|
||||||
|
|
||||||
provide('shouldOmitHeaderTitle', true);
|
provide('shouldOmitHeaderTitle', true);
|
||||||
|
|
||||||
|
@ -121,14 +124,7 @@ const withSensitive = computed<boolean>({
|
||||||
get: () => defaultStore.reactiveState.tl.value.filter.withSensitive,
|
get: () => defaultStore.reactiveState.tl.value.filter.withSensitive,
|
||||||
set: (x) => saveTlFilter('withSensitive', x),
|
set: (x) => saveTlFilter('withSensitive', x),
|
||||||
});
|
});
|
||||||
const isShowMediaTimeline = ref(defaultStore.state.showMediaTimeline);
|
|
||||||
const remoteLocalTimelineEnable1 = ref(defaultStore.state.remoteLocalTimelineEnable1);
|
|
||||||
const remoteLocalTimelineEnable2 = ref(defaultStore.state.remoteLocalTimelineEnable2);
|
|
||||||
const remoteLocalTimelineEnable3 = ref(defaultStore.state.remoteLocalTimelineEnable3);
|
|
||||||
const remoteLocalTimelineEnable4 = ref(defaultStore.state.remoteLocalTimelineEnable4);
|
|
||||||
const remoteLocalTimelineEnable5 = ref(defaultStore.state.remoteLocalTimelineEnable5);
|
|
||||||
const showHomeTimeline = ref(defaultStore.state.showHomeTimeline);
|
|
||||||
const showSocialTimeline = ref(defaultStore.state.showSocialTimeline);
|
|
||||||
const channelInfo = ref();
|
const channelInfo = ref();
|
||||||
if (src.value.split(':')[0] === 'channel') {
|
if (src.value.split(':')[0] === 'channel') {
|
||||||
const channelId = src.value.split(':')[1];
|
const channelId = src.value.split(':')[1];
|
||||||
|
|
|
@ -10,6 +10,8 @@ import type { SoundType } from '@/scripts/sound.js';
|
||||||
import { Storage } from '@/pizzax.js';
|
import { Storage } from '@/pizzax.js';
|
||||||
import { hemisphere } from '@/scripts/intl-const.js';
|
import { hemisphere } from '@/scripts/intl-const.js';
|
||||||
import { isLocalTimelineAvailable, isGlobalTimelineAvailable } from '@/scripts/get-timeline-available.js';
|
import { isLocalTimelineAvailable, isGlobalTimelineAvailable } from '@/scripts/get-timeline-available.js';
|
||||||
|
import { instance } from '@/instance.js';
|
||||||
|
|
||||||
interface PostFormAction {
|
interface PostFormAction {
|
||||||
title: string,
|
title: string,
|
||||||
handler: <T>(form: T, update: (key: unknown, value: unknown) => void) => void;
|
handler: <T>(form: T, update: (key: unknown, value: unknown) => void) => void;
|
||||||
|
@ -534,85 +536,9 @@ export const defaultStore = markRaw(new Storage('base', {
|
||||||
where: 'device',
|
where: 'device',
|
||||||
default: 44,
|
default: 44,
|
||||||
},
|
},
|
||||||
remoteLocalTimelineDomain1: {
|
remoteLocalTimeline: {
|
||||||
where: 'account',
|
where: 'device',
|
||||||
default: '',
|
default: [],
|
||||||
},
|
|
||||||
remoteLocalTimelineDomain2: {
|
|
||||||
where: 'account',
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
remoteLocalTimelineDomain3: {
|
|
||||||
where: 'account',
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
remoteLocalTimelineDomain4: {
|
|
||||||
where: 'account',
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
remoteLocalTimelineDomain5: {
|
|
||||||
where: 'account',
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
remoteLocalTimelineToken1: {
|
|
||||||
where: 'account',
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
remoteLocalTimelineToken2: {
|
|
||||||
where: 'account',
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
remoteLocalTimelineToken3: {
|
|
||||||
where: 'account',
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
remoteLocalTimelineToken4: {
|
|
||||||
where: 'account',
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
remoteLocalTimelineToken5: {
|
|
||||||
where: 'account',
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
remoteLocalTimelineEnable1: {
|
|
||||||
where: 'account',
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
remoteLocalTimelineEnable2: {
|
|
||||||
where: 'account',
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
remoteLocalTimelineEnable3: {
|
|
||||||
where: 'account',
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
remoteLocalTimelineEnable4: {
|
|
||||||
where: 'account',
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
remoteLocalTimelineEnable5: {
|
|
||||||
where: 'account',
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
remoteLocalTimelineName1: {
|
|
||||||
where: 'account',
|
|
||||||
default: 'custom timeline 1',
|
|
||||||
},
|
|
||||||
remoteLocalTimelineName2: {
|
|
||||||
where: 'account',
|
|
||||||
default: 'custom timeline 2 ',
|
|
||||||
},
|
|
||||||
remoteLocalTimelineName3: {
|
|
||||||
where: 'account',
|
|
||||||
default: 'custom timeline 3',
|
|
||||||
},
|
|
||||||
remoteLocalTimelineName4: {
|
|
||||||
where: 'account',
|
|
||||||
default: 'custom timeline 4',
|
|
||||||
},
|
|
||||||
remoteLocalTimelineName5: {
|
|
||||||
where: 'account',
|
|
||||||
default: 'custom timeline 5',
|
|
||||||
},
|
},
|
||||||
onlyAndWithSave: {
|
onlyAndWithSave: {
|
||||||
where: 'device',
|
where: 'device',
|
||||||
|
|
|
@ -3,10 +3,18 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { reactive } from 'vue';
|
import { computed, reactive, ref } from 'vue';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { userListsCache } from '@/cache.js';
|
import {
|
||||||
|
antennasCache,
|
||||||
|
userChannelFollowingsCache,
|
||||||
|
userChannelsCache,
|
||||||
|
userFavoriteListsCache,
|
||||||
|
userListsCache,
|
||||||
|
} from '@/cache.js';
|
||||||
import { isLocalTimelineAvailable, isGlobalTimelineAvailable } from '@/scripts/get-timeline-available.js';
|
import { isLocalTimelineAvailable, isGlobalTimelineAvailable } from '@/scripts/get-timeline-available.js';
|
||||||
|
import { defaultStore } from '@/store.js';
|
||||||
|
import { $i } from '@/account.js';
|
||||||
|
|
||||||
export type TimelineHeaderItem =
|
export type TimelineHeaderItem =
|
||||||
'home' |
|
'home' |
|
||||||
|
@ -16,7 +24,11 @@ export type TimelineHeaderItem =
|
||||||
'lists' |
|
'lists' |
|
||||||
'antennas' |
|
'antennas' |
|
||||||
'channels' |
|
'channels' |
|
||||||
`list:${string}`
|
`list:${string}` |
|
||||||
|
`channel:${string}` |
|
||||||
|
`antenna:${string}` |
|
||||||
|
'media' |
|
||||||
|
`customTimeline:${string}`;
|
||||||
|
|
||||||
type TimelineHeaderItemsDef = {
|
type TimelineHeaderItemsDef = {
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -25,6 +37,11 @@ type TimelineHeaderItemsDef = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const lists = await userListsCache.fetch();
|
const lists = await userListsCache.fetch();
|
||||||
|
const userChannels = await userChannelsCache.fetch();
|
||||||
|
const userChannelFollowings = await userChannelFollowingsCache.fetch();
|
||||||
|
const userFavoriteLists = await userFavoriteListsCache.fetch();
|
||||||
|
const antenna = await antennasCache.fetch();
|
||||||
|
|
||||||
export const timelineHeaderItemDef = reactive<Partial<Record<TimelineHeaderItem, TimelineHeaderItemsDef>>>({
|
export const timelineHeaderItemDef = reactive<Partial<Record<TimelineHeaderItem, TimelineHeaderItemsDef>>>({
|
||||||
home: {
|
home: {
|
||||||
title: i18n.ts._timelines.home,
|
title: i18n.ts._timelines.home,
|
||||||
|
@ -70,4 +87,46 @@ export const timelineHeaderItemDef = reactive<Partial<Record<TimelineHeaderItem,
|
||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
}, {}),
|
}, {}),
|
||||||
|
...userChannels.reduce((acc, l) => {
|
||||||
|
acc['channel:' + l.id] = {
|
||||||
|
title: i18n.ts.channel + ':' + l.name,
|
||||||
|
icon: 'ti ti-star',
|
||||||
|
iconOnly: true,
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
...userChannelFollowings.reduce((acc, l) => {
|
||||||
|
acc['channel:' + l.id] = {
|
||||||
|
title: i18n.ts.channel + ':' + l.name,
|
||||||
|
icon: 'ti ti-star',
|
||||||
|
iconOnly: true,
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
...userFavoriteLists.reduce((acc, l) => {
|
||||||
|
acc['channel:' + l.id] = {
|
||||||
|
title: i18n.ts.channel + ':' + l.name,
|
||||||
|
icon: 'ti ti-star',
|
||||||
|
iconOnly: true,
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
...antenna.reduce((acc, l) => {
|
||||||
|
acc['antenna:' + l.id] = {
|
||||||
|
title: i18n.ts.antennas + ':' + l.name,
|
||||||
|
icon: 'ti ti-star',
|
||||||
|
iconOnly: true,
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
...defaultStore.reactiveState.remoteLocalTimeline.value.reduce((acc, t) => {
|
||||||
|
acc['remoteLocalTimeline:' + t.host.replace('https://', '')] = {
|
||||||
|
title: 'remoteLocaltimeline:' + t.name,
|
||||||
|
icon: 'ti ti-star',
|
||||||
|
iconOnly: true,
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
260
packages/frontend/src/ui/twilike.sidebar.vue
Normal file
260
packages/frontend/src/ui/twilike.sidebar.vue
Normal file
|
@ -0,0 +1,260 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="npcljfve" :class="{ iconOnly }">
|
||||||
|
<div class="about">
|
||||||
|
<button v-click-anime class="item _button" @click="openInstanceMenu">
|
||||||
|
<img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" class="_ghost"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<MkA v-click-anime class="item index" activeClass="active" to="/" exact>
|
||||||
|
<i class="ti ti-home ti-fw"></i><span class="text">{{ i18n.ts.timeline }}</span>
|
||||||
|
</MkA>
|
||||||
|
<template v-for="item in menu">
|
||||||
|
<div v-if="item === '-'" class="divider"></div>
|
||||||
|
<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="item" activeClass="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
|
||||||
|
<i class="ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span>
|
||||||
|
<span v-if="navbarItemDef[item].indicated" class="indicator">
|
||||||
|
<span v-if="navbarItemDef[item].indicateValue" class="_indicateCounter itemIndicateValueIcon">{{ navbarItemDef[item].indicateValue }}</span>
|
||||||
|
<i v-else class="_indicatorCircle"></i>
|
||||||
|
</span>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
<MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" activeClass="active" to="/admin" :behavior="settingsWindowed ? 'window' : null">
|
||||||
|
<i class="ti ti-dashboard ti-fw"></i><span class="text">{{ i18n.ts.controlPanel }}</span>
|
||||||
|
</MkA>
|
||||||
|
<button v-click-anime class="item _button" @click="more">
|
||||||
|
<i class="ti ti-dots ti-fw"></i><span class="text">{{ i18n.ts.more }}</span>
|
||||||
|
<span v-if="otherNavItemIndicated" class="indicator"><i class="_indicatorCircle"></i></span>
|
||||||
|
</button>
|
||||||
|
<MkA v-click-anime class="item" activeClass="active" to="/settings" :behavior="settingsWindowed ? 'window' : null">
|
||||||
|
<i class="ti ti-settings ti-fw"></i><span class="text">{{ i18n.ts.settings }}</span>
|
||||||
|
</MkA>
|
||||||
|
|
||||||
|
<div class="post" data-cy-open-post-form @click="os.post">
|
||||||
|
<MkButton class="button" gradate full rounded>
|
||||||
|
<div class="content">
|
||||||
|
<i class="ti ti-pencil ti-fw"></i>
|
||||||
|
<span v-if="!iconOnly" class="text">{{ i18n.ts.note }}</span>
|
||||||
|
</div>
|
||||||
|
</MkButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-click-anime class="item _button account" @click="openAccountMenu">
|
||||||
|
<MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!--<MisskeyLogo class="misskey"/>-->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { defineAsyncComponent, computed, watch, ref, shallowRef, onUnmounted, onMounted } from 'vue';
|
||||||
|
import { openInstanceMenu } from './_common_/common.js';
|
||||||
|
// import { host } from '@/config.js';
|
||||||
|
import * as os from '@/os.js';
|
||||||
|
import { navbarItemDef } from '@/navbar.js';
|
||||||
|
import { openAccountMenu as openAccountMenu_, $i } from '@/account.js';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
// import { StickySidebar } from '@/scripts/sticky-sidebar.js';
|
||||||
|
// import { mainRouter } from '@/router.js';
|
||||||
|
//import MisskeyLogo from '@assets/client/misskey.svg';
|
||||||
|
import { defaultStore } from '@/store.js';
|
||||||
|
import { instance } from '@/instance.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import { mainRouter } from '@/router/main.js';
|
||||||
|
|
||||||
|
const WINDOW_THRESHOLD = 1400;
|
||||||
|
|
||||||
|
const menu = ref(defaultStore.state.menu);
|
||||||
|
const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay'));
|
||||||
|
const otherNavItemIndicated = computed<boolean>(() => {
|
||||||
|
for (const def in navbarItemDef) {
|
||||||
|
if (menu.value.includes(def)) continue;
|
||||||
|
if (navbarItemDef[def].indicated) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
const el = shallowRef<HTMLElement>();
|
||||||
|
// let accounts = $ref([]);
|
||||||
|
// let connection = $ref(null);
|
||||||
|
const iconOnly = ref(false);
|
||||||
|
const settingsWindowed = ref(false);
|
||||||
|
|
||||||
|
function calcViewState() {
|
||||||
|
iconOnly.value = (window.innerWidth <= WINDOW_THRESHOLD) || (menuDisplay.value === 'sideIcon');
|
||||||
|
settingsWindowed.value = (window.innerWidth > WINDOW_THRESHOLD);
|
||||||
|
}
|
||||||
|
|
||||||
|
function more(ev: MouseEvent) {
|
||||||
|
os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
|
||||||
|
src: ev.currentTarget ?? ev.target,
|
||||||
|
}, {}, 'closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAccountMenu(ev: MouseEvent) {
|
||||||
|
openAccountMenu_({
|
||||||
|
withExtraOperation: true,
|
||||||
|
}, ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
let updateIconOnly: () => void;
|
||||||
|
watch(defaultStore.reactiveState.menuDisplay, () => {
|
||||||
|
calcViewState();
|
||||||
|
});
|
||||||
|
onMounted(() => {
|
||||||
|
updateIconOnly = () => {
|
||||||
|
iconOnly.value = window.innerWidth < 1300;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', updateIconOnly);
|
||||||
|
|
||||||
|
updateIconOnly(); // 初期値を設定
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', updateIconOnly);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
|
||||||
|
.npcljfve {
|
||||||
|
$ui-font-size: 1em; // TODO: どこかに集約したい
|
||||||
|
$nav-icon-only-width: 78px; // TODO: どこかに集約したい
|
||||||
|
$avatar-size: 32px;
|
||||||
|
$avatar-margin: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 260px;
|
||||||
|
height: 100%;
|
||||||
|
&.iconOnly {
|
||||||
|
flex: 0 0 $nav-icon-only-width;
|
||||||
|
width: $nav-icon-only-width !important;
|
||||||
|
|
||||||
|
> .divider {
|
||||||
|
margin: 8px auto;
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
> .post {
|
||||||
|
> .button {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .item {
|
||||||
|
padding-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $ui-font-size * 1.5;
|
||||||
|
line-height: 3.7rem;
|
||||||
|
|
||||||
|
> i,
|
||||||
|
> .avatar {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
> i {
|
||||||
|
left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .post {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 16px 0;
|
||||||
|
background: var(--bg);
|
||||||
|
|
||||||
|
> .button {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
font-size: larger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .about {
|
||||||
|
fill: currentColor;
|
||||||
|
padding: 8px 0 16px 0;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
> .item {
|
||||||
|
display: block;
|
||||||
|
width: 32px;
|
||||||
|
margin: 0 auto;
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .item {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
font-size: $ui-font-size * 1.2;
|
||||||
|
line-height: 3rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 58px;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 4px 4px;
|
||||||
|
> i {
|
||||||
|
width: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> i,
|
||||||
|
> .avatar {
|
||||||
|
margin-right: $avatar-margin;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .avatar {
|
||||||
|
width: $avatar-size;
|
||||||
|
height: $avatar-size;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .indicator {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
color: var(--navIndicator);
|
||||||
|
font-size: 8px;
|
||||||
|
animation: global-blink 1s infinite;
|
||||||
|
|
||||||
|
&:has(.itemIndicateValueIcon) {
|
||||||
|
animation: none;
|
||||||
|
left: auto;
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--navHoverFg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: var(--navActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
463
packages/frontend/src/ui/twilike.vue
Normal file
463
packages/frontend/src/ui/twilike.vue
Normal file
|
@ -0,0 +1,463 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="[$style.root,{ wallpaper }]" :style="`--globalHeaderHeight:${globalHeaderHeight}px`">
|
||||||
|
<XHeaderMenu v-if="showMenuOnTop" v-get-size="(w, h) => globalHeaderHeight = h"/>
|
||||||
|
|
||||||
|
<div :class="[$style.columns,{ [$style.fullView]:fullView, [$style.withGlobalHeader]: showMenuOnTop }]">
|
||||||
|
<div v-if="!showMenuOnTop && isDesktop" :class="$style.sidebar">
|
||||||
|
<XSidebar/>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!pageMetadata?.needWideArea && isDesktop" ref="widgetsLeft" :class="[$style.widgets,$style.left]">
|
||||||
|
<XWidgets place="left" :marginTop="'var(--margin)'"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main :class="[$style.main, {[$style.wide]: pageMetadata?.needWideArea} ]" @contextmenu.stop="onContextmenu">
|
||||||
|
<RouterView/>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div v-if="isDesktop && !pageMetadata?.needWideArea" ref="widgetsRight" :class="$style.widgets">
|
||||||
|
<XWidgets :place="showMenuOnTop ? 'right' : null" :marginTop="showMenuOnTop ? '0' : 'var(--margin)'"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition :name="defaultStore.state.animation ? 'tray-back' : ''">
|
||||||
|
<div
|
||||||
|
v-if="widgetsShowing"
|
||||||
|
class="tray-back _modalBg"
|
||||||
|
@click="widgetsShowing = false"
|
||||||
|
@touchstart.passive="widgetsShowing = false"
|
||||||
|
></div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<XCommon/>
|
||||||
|
<div v-if="!isDesktop" ref="navFooter" :class="$style.nav">
|
||||||
|
<button :class="$style.navButton" class="_button" @click="drawerMenuShowing = true"><i :class="$style.navButtonIcon" class="ti ti-menu-2"></i><span v-if="menuIndicated" :class="$style.navButtonIndicator"><i class="_indicatorCircle"></i></span></button>
|
||||||
|
<button :class="$style.navButton" class="_button" @click="isRoot ? top() : mainRouter.push('/')"><i :class="$style.navButtonIcon" class="ti ti-home"></i></button>
|
||||||
|
<button :class="$style.navButton" class="_button" @click="mainRouter.push('/my/notifications')">
|
||||||
|
<i :class="$style.navButtonIcon" class="ti ti-bell"></i>
|
||||||
|
<span v-if="$i?.hasUnreadNotification" :class="$style.navButtonIndicator">
|
||||||
|
<span class="_indicateCounter" :class="$style.itemIndicateValueIcon">{{ $i.unreadNotificationsCount > 99 ? '99+' : $i.unreadNotificationsCount }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button :class="$style.navButton" class="_button" @click="widgetsShowing = true"><i :class="$style.navButtonIcon" class="ti ti-apps"></i></button>
|
||||||
|
<button :class="$style.navButton" class="_button" @click="os.post()"><i :class="$style.navButtonIcon" class="ti ti-pencil"></i></button>
|
||||||
|
</div>
|
||||||
|
<Transition
|
||||||
|
:enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterActive : ''"
|
||||||
|
:leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveActive : ''"
|
||||||
|
:enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_enterFrom : ''"
|
||||||
|
:leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawerBg_leaveTo : ''"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="drawerMenuShowing || widgetsShowing"
|
||||||
|
:class="$style.menuDrawerBg"
|
||||||
|
class="_modalBg"
|
||||||
|
@click="drawerMenuShowing = false; widgetsShowing = false "
|
||||||
|
@touchstart.passive="drawerMenuShowing = false; widgetsShowing = false"
|
||||||
|
></div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
:enterActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterActive : ''"
|
||||||
|
:leaveActiveClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveActive : ''"
|
||||||
|
:enterFromClass="defaultStore.state.animation ? $style.transition_menuDrawer_enterFrom : ''"
|
||||||
|
:leaveToClass="defaultStore.state.animation ? $style.transition_menuDrawer_leaveTo : ''"
|
||||||
|
>
|
||||||
|
<div v-if="drawerMenuShowing" :class="$style.menuDrawer">
|
||||||
|
<XDrawerMenu/>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
<Transition
|
||||||
|
:enterActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterActive : ''"
|
||||||
|
:leaveActiveClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveActive : ''"
|
||||||
|
:enterFromClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_enterFrom : ''"
|
||||||
|
:leaveToClass="defaultStore.state.animation ? $style.transition_widgetsDrawer_leaveTo : ''"
|
||||||
|
>
|
||||||
|
<div v-if="widgetsShowing" :class="$style.widgetsDrawer">
|
||||||
|
<button class="_button" :class="$style.widgetsCloseButton" @click="widgetsShowing = false"><i class="ti ti-x"></i></button>
|
||||||
|
<XWidgets/>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { defineAsyncComponent, onMounted, provide, ref, computed, shallowRef, watch } from 'vue';
|
||||||
|
import XSidebar from './twilike.sidebar.vue';
|
||||||
|
import XCommon from './_common_/common.vue';
|
||||||
|
import { instanceName, ui } from '@/config.js';
|
||||||
|
import * as os from '@/os.js';
|
||||||
|
import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
|
||||||
|
import { defaultStore } from '@/store.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import { miLocalStorage } from '@/local-storage.js';
|
||||||
|
import { mainRouter } from '@/router/main.js';
|
||||||
|
import { $i } from '@/account.js';
|
||||||
|
import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
|
||||||
|
const XHeaderMenu = defineAsyncComponent(() => import('./classic.header.vue'));
|
||||||
|
const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue'));
|
||||||
|
|
||||||
|
const isRoot = computed(() => mainRouter.currentRoute.value.name === 'index');
|
||||||
|
|
||||||
|
const DESKTOP_THRESHOLD = 700;
|
||||||
|
|
||||||
|
const isDesktop = ref(window.innerWidth >= DESKTOP_THRESHOLD);
|
||||||
|
const drawerMenuShowing = ref(false);
|
||||||
|
const pageMetadata = ref<null | PageMetadata>(null);
|
||||||
|
const widgetsShowing = ref(false);
|
||||||
|
const fullView = ref(false);
|
||||||
|
const globalHeaderHeight = ref(0);
|
||||||
|
const wallpaper = miLocalStorage.getItem('wallpaper') != null;
|
||||||
|
const showMenuOnTop = computed(() => defaultStore.state.menuDisplay === 'top');
|
||||||
|
const widgetsLeft = ref<HTMLElement>();
|
||||||
|
const widgetsRight = ref<HTMLElement>();
|
||||||
|
|
||||||
|
provide('router', mainRouter);
|
||||||
|
provideMetadataReceiver((metadataGetter) => {
|
||||||
|
const info = metadataGetter();
|
||||||
|
pageMetadata.value = info;
|
||||||
|
if (mainRouter.currentRoute.value.path.split('/').slice(1)[0] === 'settings') {
|
||||||
|
pageMetadata.value.needWideArea = true;
|
||||||
|
}
|
||||||
|
if (pageMetadata.value) {
|
||||||
|
if (isRoot.value && pageMetadata.value.title === instanceName) {
|
||||||
|
document.title = pageMetadata.value.title;
|
||||||
|
} else {
|
||||||
|
document.title = `${pageMetadata.value.title} | ${instanceName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
provideReactiveMetadata(pageMetadata);
|
||||||
|
provide('shouldHeaderThin', showMenuOnTop.value);
|
||||||
|
provide('forceSpacerMin', true);
|
||||||
|
|
||||||
|
function onContextmenu(ev: MouseEvent) {
|
||||||
|
const isLink = (el: HTMLElement) => {
|
||||||
|
if (el.tagName === 'A') return true;
|
||||||
|
if (el.parentElement) {
|
||||||
|
return isLink(el.parentElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (isLink(ev.target)) return;
|
||||||
|
if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return;
|
||||||
|
if (window.getSelection().toString() !== '') return;
|
||||||
|
const path = mainRouter.getCurrentPath();
|
||||||
|
os.contextMenu([{
|
||||||
|
type: 'label',
|
||||||
|
text: path,
|
||||||
|
}, {
|
||||||
|
icon: fullView.value ? 'ti ti-minimize' : 'ti ti-maximize',
|
||||||
|
text: fullView.value ? i18n.ts.quitFullView : i18n.ts.fullView,
|
||||||
|
action: () => {
|
||||||
|
fullView.value = !fullView.value;
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
icon: 'ti ti-window-maximize',
|
||||||
|
text: i18n.ts.openInWindow,
|
||||||
|
action: () => {
|
||||||
|
os.pageWindow(path);
|
||||||
|
},
|
||||||
|
}], ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.style.overflowY = 'scroll';
|
||||||
|
|
||||||
|
defaultStore.loaded.then(() => {
|
||||||
|
if (defaultStore.state.widgets.length === 0) {
|
||||||
|
defaultStore.set('widgets', [{
|
||||||
|
name: 'calendar',
|
||||||
|
id: 'a', place: null, data: {},
|
||||||
|
}, {
|
||||||
|
name: 'notifications',
|
||||||
|
id: 'b', place: null, data: {},
|
||||||
|
}, {
|
||||||
|
name: 'trends',
|
||||||
|
id: 'c', place: null, data: {},
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
isDesktop.value = (window.innerWidth >= DESKTOP_THRESHOLD);
|
||||||
|
}, { passive: true });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
$ui-font-size: 1em;
|
||||||
|
$widgets-hide-threshold: 1200px;
|
||||||
|
.transition_widgetsDrawer_enterActive,
|
||||||
|
.transition_widgetsDrawer_leaveActive {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
}
|
||||||
|
.transition_widgetsDrawer_enterFrom,
|
||||||
|
.transition_widgetsDrawer_leaveTo {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(240px);
|
||||||
|
}
|
||||||
|
.tray-enter-active,
|
||||||
|
.tray-leave-active {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
}
|
||||||
|
.tray-enter-from,
|
||||||
|
.tray-leave-active {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(240px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tray-back-enter-active,
|
||||||
|
.tray-back-leave-active {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
}
|
||||||
|
.tray-back-enter-from,
|
||||||
|
.tray-back-leave-active {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition_menuDrawerBg_enterActive,
|
||||||
|
.transition_menuDrawerBg_leaveActive {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
}
|
||||||
|
.transition_menuDrawerBg_enterFrom,
|
||||||
|
.transition_menuDrawerBg_leaveTo {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition_menuDrawer_enterActive,
|
||||||
|
.transition_menuDrawer_leaveActive {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
}
|
||||||
|
.transition_menuDrawer_enterFrom,
|
||||||
|
.transition_menuDrawer_leaveTo {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-240px);
|
||||||
|
}
|
||||||
|
@media (min-width: 700px) {
|
||||||
|
.root{
|
||||||
|
padding-right:48px;
|
||||||
|
padding-left:24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.wide{
|
||||||
|
&.main {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 900px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.root {
|
||||||
|
|
||||||
|
min-height: 100dvh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&.wallpaper {
|
||||||
|
background: var(--wallpaperOverlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
> .tray-back {
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .tray {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1001;
|
||||||
|
height: 100dvh;
|
||||||
|
margin-top: var(--stickyTop);
|
||||||
|
padding: var(--margin) var(--margin) calc(var(--margin) + env(safe-area-inset-bottom, 0px));
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
> .ivnzpscs {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 300px;
|
||||||
|
height: 600px;
|
||||||
|
border: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.columns {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
&.fullView {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
> .sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .widgets {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .main {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
width: 750px;
|
||||||
|
margin: 0 16px 0 0;
|
||||||
|
border-left: solid 1px var(--divider);
|
||||||
|
border-right: solid 1px var(--divider);
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: clip;
|
||||||
|
--margin: 12px;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .widgets {
|
||||||
|
//--panelBorder: none;
|
||||||
|
width: 300px;
|
||||||
|
height: 100vh;
|
||||||
|
padding-bottom: calc(var(--margin) + env(safe-area-inset-bottom, 0px));
|
||||||
|
position: sticky;
|
||||||
|
overflow-y: auto;
|
||||||
|
top: 0;
|
||||||
|
padding-top: 16px;
|
||||||
|
|
||||||
|
@media (max-width: $widgets-hide-threshold) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.left {
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .sidebar {
|
||||||
|
width: 275px;
|
||||||
|
height: 100vh;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.withGlobalHeader {
|
||||||
|
> .main {
|
||||||
|
margin-top: 0;
|
||||||
|
border: solid 1px var(--divider);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
--stickyTop: var(--globalHeaderHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
> .widgets {
|
||||||
|
--stickyTop: var(--globalHeaderHeight);
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 1300px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
.main {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 850px) {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
> .sidebar {
|
||||||
|
border-right: solid 0.5px var(--divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
> .main {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.nav {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
padding: 12px 12px max(12px, env(safe-area-inset-bottom, 0px)) 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
|
||||||
|
grid-gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
-webkit-backdrop-filter: var(--blur, blur(24px));
|
||||||
|
backdrop-filter: var(--blur, blur(24px));
|
||||||
|
background-color: var(--bg);
|
||||||
|
border-top: solid 0.5px var(--divider);
|
||||||
|
height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuDrawerBg {
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuDrawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1001;
|
||||||
|
height: 100dvh;
|
||||||
|
width: 240px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
contain: strict;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
background: var(--navBg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navButton{
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
.widgetsDrawerBg {
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widgetsDrawer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1001;
|
||||||
|
width: 310px;
|
||||||
|
height: 100dvh;
|
||||||
|
padding: var(--margin) var(--margin) calc(var(--margin) + env(safe-area-inset-bottom, 0px)) !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.widgetsCloseButton {
|
||||||
|
padding: 8px;
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
Loading…
Reference in a new issue