merge: Support following feed in Deck UI (resolves #789) (!724)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/724

Closes #789

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
dakkar 2024-11-22 23:02:31 +00:00
commit 59afb56b5b
14 changed files with 589 additions and 243 deletions

8
locales/index.d.ts vendored
View File

@ -9661,6 +9661,10 @@ export interface Locale extends ILocale {
* *
*/ */
"roleTimeline": string; "roleTimeline": string;
/**
* Following
*/
"following": string;
}; };
}; };
"_dialog": { "_dialog": {
@ -11374,6 +11378,10 @@ export interface Locale extends ILocale {
* Remote followers may have incomplete or outdated activity * Remote followers may have incomplete or outdated activity
*/ */
"remoteFollowersWarning": string; "remoteFollowersWarning": string;
/**
* Select a follow relationship...
*/
"selectFollowRelationship": string;
} }
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View File

@ -0,0 +1,144 @@
<!--
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkPullToRefresh :refresher="() => reload()">
<MkPagination ref="latestNotesPaging" :pagination="latestNotesPagination" @init="onListReady">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" class="_ghost" :alt="i18n.ts.noNotes" aria-hidden="true"/>
<div>{{ i18n.ts.noNotes }}</div>
</div>
</template>
<template #default="{ items: notes }">
<MkDateSeparatedList v-slot="{ item: note }" :items="notes" :class="$style.panel" :noGap="true">
<SkFollowingFeedEntry v-if="!isHardMuted(note)" :isMuted="isSoftMuted(note)" :note="note" :class="props.selectedUserId == note.userId && $style.selected" @select="u => selectUser(u.id)"/>
</MkDateSeparatedList>
</template>
</MkPagination>
</MkPullToRefresh>
</template>
<script setup lang="ts">
import * as Misskey from 'misskey-js';
import { computed, shallowRef } from 'vue';
import { infoImageUrl } from '@/instance.js';
import { i18n } from '@/i18n.js';
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
import MkPagination, { Paging } from '@/components/MkPagination.vue';
import SkFollowingFeedEntry from '@/components/SkFollowingFeedEntry.vue';
import { $i } from '@/account.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import { FollowingFeedTab } from '@/scripts/following-feed-utils.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
const props = defineProps<{
userList: FollowingFeedTab;
withNonPublic: boolean;
withQuotes: boolean;
withReplies: boolean;
withBots: boolean;
onlyFiles: boolean;
selectedUserId?: string | null;
}>();
const emit = defineEmits<{
(event: 'loaded', initialUserId?: string): void;
(event: 'userSelected', userId: string): void;
}>();
defineExpose({ reload });
async function reload() {
await latestNotesPaging.value?.reload();
}
function selectUser(userId: string) {
emit('userSelected', userId);
}
async function onListReady(): Promise<void> {
// This looks complicated, but it's really just a trick to get the first user ID from the pagination.
const initialUserId = latestNotesPaging.value?.items.size
? latestNotesPaging.value.items.values().next().value?.userId
: undefined;
emit('loaded', initialUserId);
}
const latestNotesPagination: Paging<'notes/following'> = {
endpoint: 'notes/following' as const,
limit: 20,
params: computed(() => ({
list: props.userList,
filesOnly: props.onlyFiles,
includeNonPublic: props.withNonPublic,
includeReplies: props.withReplies,
includeQuotes: props.withQuotes,
includeBots: props.withBots,
})),
};
const latestNotesPaging = shallowRef<InstanceType<typeof MkPagination>>();
function isSoftMuted(note: Misskey.entities.Note): boolean {
return isMuted(note, $i?.mutedWords);
}
function isHardMuted(note: Misskey.entities.Note): boolean {
return isMuted(note, $i?.hardMutedWords);
}
// Match the typing used by Misskey
type Mutes = (string | string[])[] | null | undefined;
// Adapted from MkNote.ts
function isMuted(note: Misskey.entities.Note, mutes: Mutes): boolean {
return checkMute(note, mutes)
|| checkMute(note.reply, mutes)
|| checkMute(note.renote, mutes);
}
// Adapted from check-word-mute.ts
function checkMute(note: Misskey.entities.Note | undefined | null, mutes: Mutes): boolean {
if (!note) {
return false;
}
if (!mutes || mutes.length < 1) {
return false;
}
return checkWordMute(note, $i, mutes);
}
</script>
<style module lang="scss">
.panel {
background: var(--panel);
}
@keyframes border {
from {
border-left: 0 solid var(--accent);
}
to {
border-left: 6px solid var(--accent);
}
}
.selected {
animation: border 0.2s ease-out 0s 1 forwards;
&:first-child {
border-top-left-radius: 5px;
}
&:last-child {
border-bottom-left-radius: 5px;
}
}
</style>

View File

@ -0,0 +1,32 @@
<!--
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkInfo v-if="showRemoteWarning" warn closable @close="close">
{{ i18n.ts.remoteFollowersWarning }}
</MkInfo>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { i18n } from '@/i18n.js';
import MkInfo from '@/components/MkInfo.vue';
import { followersTab, FollowingFeedModel } from '@/scripts/following-feed-utils.js';
const props = defineProps<{
model: FollowingFeedModel,
}>();
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const { model: { userList, remoteWarningDismissed } } = props;
const showRemoteWarning = computed(
() => userList.value === followersTab && !remoteWarningDismissed.value,
);
function close() {
remoteWarningDismissed.value = true;
}
</script>

View File

@ -101,7 +101,7 @@ onMounted(async () => {
margin-bottom: 12px; margin-bottom: 12px;
} }
@container (min-width: 451px) { @container (min-width: 750px) {
.userInfo { .userInfo {
margin-bottom: 24px; margin-bottom: 24px;
} }

View File

@ -0,0 +1,57 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<!-- Based on MkLazy.vue -->
<template>
<div ref="rootEl" :class="$style.root">
<slot v-if="showing"></slot>
<div v-else :class="$style.placeholder"></div>
</div>
</template>
<script lang="ts" setup>
import { nextTick, onMounted, onActivated, onBeforeUnmount, ref, shallowRef } from 'vue';
const rootEl = shallowRef<HTMLDivElement>();
const showing = ref(false);
defineExpose({ rootEl, showing });
const observer = new IntersectionObserver(entries =>
showing.value = entries.some((entry) => entry.isIntersecting),
);
onMounted(() => {
nextTick(() => {
if (rootEl.value) {
observer.observe(rootEl.value);
}
});
});
onActivated(() => {
nextTick(() => {
if (rootEl.value) {
observer.observe(rootEl.value);
}
});
});
onBeforeUnmount(() => {
observer.disconnect();
});
</script>
<style lang="scss" module>
.root {
display: block;
}
.placeholder {
display: block;
min-height: 150px;
}
</style>

View File

@ -7,61 +7,41 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.root"> <div :class="$style.root">
<div :class="$style.header"> <div :class="$style.header">
<MkPageHeader v-model:tab="userList" :tabs="headerTabs" :actions="headerActions" :displayBackButton="true" @update:tab="onChangeTab"/> <MkPageHeader v-model:tab="userList" :tabs="headerTabs" :actions="headerActions" :displayBackButton="true" @update:tab="onChangeTab"/>
<MkInfo v-if="showRemoteWarning" :class="$style.remoteWarning" warn closable @close="remoteWarningDismissed = true">{{ i18n.ts.remoteFollowersWarning }}</MkInfo> <SkRemoteFollowersWarning :class="$style.remoteWarning" :model="model"/>
</div> </div>
<div ref="noteScroll" :class="$style.notes"> <div ref="noteScroll" :class="$style.notes">
<MkHorizontalSwipe v-model:tab="userList" :tabs="headerTabs"> <MkHorizontalSwipe v-model:tab="userList" :tabs="headerTabs">
<MkPullToRefresh :refresher="() => reloadLatestNotes()"> <SkFollowingRecentNotes ref="followingRecentNotes" :selectedUserId="selectedUserId" :userList="userList" :withNonPublic="withNonPublic" :withQuotes="withQuotes" :withBots="withBots" :withReplies="withReplies" :onlyFiles="onlyFiles" @userSelected="userSelected" @loaded="listReady"/>
<MkPagination ref="latestNotesPaging" :pagination="latestNotesPagination" @init="onListReady">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" class="_ghost" :alt="i18n.ts.noNotes" aria-hidden="true"/>
<div>{{ i18n.ts.noNotes }}</div>
</div>
</template>
<template #default="{ items: notes }">
<MkDateSeparatedList v-slot="{ item: note }" :items="notes" :class="$style.panel" :noGap="true">
<SkFollowingFeedEntry v-if="!isHardMuted(note)" :isMuted="isSoftMuted(note)" :note="note" :class="selectedUserId == note.userId && $style.selected" @select="userSelected"/>
</MkDateSeparatedList>
</template>
</MkPagination>
</MkPullToRefresh>
</MkHorizontalSwipe> </MkHorizontalSwipe>
</div> </div>
<div v-if="isWideViewport" ref="userScroll" :class="$style.user"> <SkLazy ref="userScroll" :class="$style.user">
<MkHorizontalSwipe v-if="selectedUserId" v-model:tab="userList" :tabs="headerTabs"> <MkHorizontalSwipe v-if="selectedUserId" v-model:tab="userList" :tabs="headerTabs">
<SkUserRecentNotes ref="userRecentNotes" :userId="selectedUserId" :withNonPublic="withNonPublic" :withQuotes="withQuotes" :withBots="withBots" :withReplies="withReplies" :onlyFiles="onlyFiles"/> <SkUserRecentNotes ref="userRecentNotes" :userId="selectedUserId" :withNonPublic="withNonPublic" :withQuotes="withQuotes" :withBots="withBots" :withReplies="withReplies" :onlyFiles="onlyFiles"/>
</MkHorizontalSwipe> </MkHorizontalSwipe>
</div> </SkLazy>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, Ref, ref, shallowRef } from 'vue'; import { computed, ComputedRef, Ref, ref, shallowRef } from 'vue';
import * as Misskey from 'misskey-js';
import { getScrollContainer } from '@@/js/scroll.js'; import { getScrollContainer } from '@@/js/scroll.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { infoImageUrl } from '@/instance.js';
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
import { Tab } from '@/components/global/MkPageHeader.tabs.vue'; import { Tab } from '@/components/global/MkPageHeader.tabs.vue';
import { PageHeaderItem } from '@/types/page-header.js'; import { PageHeaderItem } from '@/types/page-header.js';
import SkFollowingFeedEntry from '@/components/SkFollowingFeedEntry.vue';
import { useRouter } from '@/router/supplier.js'; import { useRouter } from '@/router/supplier.js';
import MkPageHeader from '@/components/global/MkPageHeader.vue'; import MkPageHeader from '@/components/global/MkPageHeader.vue';
import { $i } from '@/account.js';
import { checkWordMute } from '@/scripts/check-word-mute.js';
import SkUserRecentNotes from '@/components/SkUserRecentNotes.vue'; import SkUserRecentNotes from '@/components/SkUserRecentNotes.vue';
import { useScrollPositionManager } from '@/nirax.js'; import { useScrollPositionManager } from '@/nirax.js';
import MkPagination, { Paging } from '@/components/MkPagination.vue'; import { createModel, createHeaderItem, followingFeedTabs, followingTabIcon, followingTabName, followingTab } from '@/scripts/following-feed-utils.js';
import MkInfo from '@/components/MkInfo.vue'; import SkLazy from '@/components/global/SkLazy.vue';
import { createModel, createOptions, followersTab, followingTab, mutualsTab } from '@/scripts/following-feed-utils.js'; import SkFollowingRecentNotes from '@/components/SkFollowingRecentNotes.vue';
import SkRemoteFollowersWarning from '@/components/SkRemoteFollowersWarning.vue';
const model = createModel();
const { const {
userList, userList,
withNonPublic, withNonPublic,
@ -69,141 +49,62 @@ const {
withBots, withBots,
withReplies, withReplies,
onlyFiles, onlyFiles,
remoteWarningDismissed, } = model;
} = createModel();
const router = useRouter(); const router = useRouter();
const userRecentNotes = shallowRef<InstanceType<typeof SkUserRecentNotes>>(); const userRecentNotes = shallowRef<InstanceType<typeof SkUserRecentNotes>>();
const userScroll = shallowRef<HTMLElement>(); const followingRecentNotes = shallowRef<InstanceType<typeof SkFollowingRecentNotes>>();
const userScroll = shallowRef<InstanceType<typeof SkLazy>>();
const noteScroll = shallowRef<HTMLElement>(); const noteScroll = shallowRef<HTMLElement>();
const showRemoteWarning = computed(() => userList.value === 'followers' && !remoteWarningDismissed.value);
// We have to disable the per-user feed on small displays, and it must be done through JS instead of CSS.
// Otherwise, the second column will waste resources in the background.
const wideViewportQuery = window.matchMedia('(min-width: 750px)');
const isWideViewport: Ref<boolean> = ref(wideViewportQuery.matches);
wideViewportQuery.addEventListener('change', () => isWideViewport.value = wideViewportQuery.matches);
const selectedUserId: Ref<string | null> = ref(null); const selectedUserId: Ref<string | null> = ref(null);
function userSelected(user: Misskey.entities.UserLite): void { function listReady(initialUserId?: string): void {
if (isWideViewport.value) { if (initialUserId && !selectedUserId.value) {
selectedUserId.value = user.id; selectedUserId.value = initialUserId;
} else {
router.push(`/following-feed/${user.id}`);
} }
} }
async function reloadLatestNotes() { function userSelected(userId: string): void {
await latestNotesPaging.value?.reload(); selectedUserId.value = userId;
}
async function reloadUserNotes() { if (!userScroll.value?.showing) {
await userRecentNotes.value?.reload(); router.push(`/following-feed/${userId}`);
}
} }
async function reload() { async function reload() {
await Promise.all([ await Promise.all([
reloadLatestNotes(), followingRecentNotes.value?.reload(),
reloadUserNotes(), userRecentNotes.value?.reload(),
]); ]);
} }
async function onListReady(): Promise<void> {
if (!selectedUserId.value && latestNotesPaging.value?.items.size) {
// This looks messy, but actually just gets the first user ID.
const selectedNote = latestNotesPaging.value.items.values().next().value;
// We know this to be non-null because of the size check above.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
selectedUserId.value = selectedNote!.userId;
}
}
async function onChangeTab(): Promise<void> { async function onChangeTab(): Promise<void> {
selectedUserId.value = null; selectedUserId.value = null;
} }
function isSoftMuted(note: Misskey.entities.Note): boolean {
return isMuted(note, $i?.mutedWords);
}
function isHardMuted(note: Misskey.entities.Note): boolean {
return isMuted(note, $i?.hardMutedWords);
}
// Match the typing used by Misskey
type Mutes = (string | string[])[] | null | undefined;
// Adapted from MkNote.ts
function isMuted(note: Misskey.entities.Note, mutes: Mutes): boolean {
return checkMute(note, mutes)
|| checkMute(note.reply, mutes)
|| checkMute(note.renote, mutes);
}
// Adapted from check-word-mute.ts
function checkMute(note: Misskey.entities.Note | undefined | null, mutes: Mutes): boolean {
if (!note) {
return false;
}
if (!mutes || mutes.length < 1) {
return false;
}
return checkWordMute(note, $i, mutes);
}
const latestNotesPaging = shallowRef<InstanceType<typeof MkPagination>>();
const latestNotesPagination: Paging<'notes/following'> = {
endpoint: 'notes/following' as const,
limit: 20,
params: computed(() => ({
list: userList.value,
filesOnly: onlyFiles.value,
includeNonPublic: withNonPublic.value,
includeReplies: withReplies.value,
includeQuotes: withQuotes.value,
includeBots: withBots.value,
})),
};
const headerActions: PageHeaderItem[] = [ const headerActions: PageHeaderItem[] = [
{ {
icon: 'ti ti-refresh', icon: 'ti ti-refresh',
text: i18n.ts.reload, text: i18n.ts.reload,
handler: () => reload(), handler: () => reload(),
}, },
createOptions(), createHeaderItem(),
]; ];
const headerTabs = computed(() => [ const headerTabs: ComputedRef<Tab[]> = computed(() => followingFeedTabs.map(t => ({
{ key: t,
key: followingTab, icon: followingTabIcon(t),
icon: 'ph-user-check ph-bold ph-lg', title: followingTabName(t),
title: i18n.ts.following, })));
} satisfies Tab,
{
key: mutualsTab,
icon: 'ph-user-switch ph-bold ph-lg',
title: i18n.ts.mutuals,
} satisfies Tab,
{
key: followersTab,
icon: 'ph-user ph-bold ph-lg',
title: i18n.ts.followers,
} satisfies Tab,
]);
useScrollPositionManager(() => getScrollContainer(userScroll.value ?? null), router); useScrollPositionManager(() => getScrollContainer(userScroll.value?.rootEl ?? null), router);
useScrollPositionManager(() => getScrollContainer(noteScroll.value ?? null), router); useScrollPositionManager(() => getScrollContainer(noteScroll.value ?? null), router);
definePageMetadata(() => ({ definePageMetadata(() => ({
title: i18n.ts.following, title: i18n.ts.following,
icon: 'ph-user-check ph-bold ph-lg', icon: followingTabIcon(followingTab),
})); }));
</script> </script>
@ -257,22 +158,13 @@ definePageMetadata(() => ({
margin-bottom: 12px; margin-bottom: 12px;
} }
@keyframes border { @container (max-width: 749px) {
from {border-left: 0px solid var(--accent);} .user {
to {border-left: 6px solid var(--accent);} display: none;
}
.selected {
animation: border 0.2s ease-out 0s 1 forwards;
&:first-child {
border-top-left-radius: 5px;
}
&:last-child {
border-bottom-left-radius: 5px;
} }
} }
@media (min-width: 750px) { @container (min-width: 750px) {
.root { .root {
grid-template-columns: min-content 4fr 6fr min-content; grid-template-columns: min-content 4fr 6fr min-content;
grid-template-rows: min-content 1fr; grid-template-rows: min-content 1fr;
@ -290,8 +182,4 @@ definePageMetadata(() => ({
margin-bottom: 24px; margin-bottom: 24px;
} }
} }
.panel {
background: var(--panel);
}
</style> </style>

View File

@ -4,16 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<MkStickyContainer ref="userScroll"> <MkStickyContainer>
<template #header> <template #header>
<MkPageHeader :actions="headerActions" :displayBackButton="true"/> <MkPageHeader :actions="headerActions" :displayBackButton="true"/>
</template> </template>
<SkUserRecentNotes ref="userRecentNotes" :userId="userId" :withNonPublic="withNonPublic" :withQuotes="withQuotes" :withBots="withBots" :withReplies="withReplies" :onlyFiles="onlyFiles"/> <SkUserRecentNotes ref="userRecentNotes" :class="$style.notes" :userId="userId" :withNonPublic="withNonPublic" :withQuotes="withQuotes" :withBots="withBots" :withReplies="withReplies" :onlyFiles="onlyFiles"/>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, shallowRef } from 'vue'; import { computed, shallowRef } from 'vue';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
@ -21,7 +20,7 @@ import { PageHeaderItem } from '@/types/page-header.js';
import MkPageHeader from '@/components/global/MkPageHeader.vue'; import MkPageHeader from '@/components/global/MkPageHeader.vue';
import SkUserRecentNotes from '@/components/SkUserRecentNotes.vue'; import SkUserRecentNotes from '@/components/SkUserRecentNotes.vue';
import { acct } from '@/filters/user.js'; import { acct } from '@/filters/user.js';
import { createModel, createOptions } from '@/scripts/following-feed-utils.js'; import { createModel, createHeaderItem } from '@/scripts/following-feed-utils.js';
import MkStickyContainer from '@/components/global/MkStickyContainer.vue'; import MkStickyContainer from '@/components/global/MkStickyContainer.vue';
defineProps<{ defineProps<{
@ -45,7 +44,7 @@ const headerActions: PageHeaderItem[] = [
text: i18n.ts.reload, text: i18n.ts.reload,
handler: () => userRecentNotes.value?.reload(), handler: () => userRecentNotes.value?.reload(),
}, },
createOptions(), createHeaderItem(),
]; ];
// Based on user/index.vue // Based on user/index.vue
@ -64,3 +63,17 @@ definePageMetadata(() => ({
} : {}, } : {},
})); }));
</script> </script>
<style lang="scss" module>
@container (min-width: 451px) {
.notes {
padding: 12px;
}
}
@container (min-width: 750px) {
.notes {
padding: 24px;
}
}
</style>

View File

@ -3,19 +3,75 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { computed } from 'vue'; import { computed, Ref, WritableComputedRef } from 'vue';
import { defaultStore } from '@/store.js'; import { defaultStore } from '@/store.js';
import { deepMerge } from '@/scripts/merge.js'; import { deepMerge } from '@/scripts/merge.js';
import { PageHeaderItem } from '@/types/page-header.js'; import { PageHeaderItem } from '@/types/page-header.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { popupMenu } from '@/os.js'; import { popupMenu } from '@/os.js';
import { MenuItem } from '@/types/menu.js';
export const followingTab = 'following' as const; export const followingTab = 'following' as const;
export const mutualsTab = 'mutuals' as const; export const mutualsTab = 'mutuals' as const;
export const followersTab = 'followers' as const; export const followersTab = 'followers' as const;
export type FollowingFeedTab = typeof followingTab | typeof mutualsTab | typeof followersTab; export const followingFeedTabs = [followingTab, mutualsTab, followersTab] as const;
export type FollowingFeedTab = typeof followingFeedTabs[number];
export function createOptions(): PageHeaderItem { export function followingTabName(tab: FollowingFeedTab): string;
export function followingTabName(tab: FollowingFeedTab | null | undefined): null;
export function followingTabName(tab: FollowingFeedTab | null | undefined): string | null {
if (tab === followingTab) return i18n.ts.following;
if (tab === followersTab) return i18n.ts.followers;
if (tab === mutualsTab) return i18n.ts.mutuals;
return null;
}
export function followingTabIcon(tab: FollowingFeedTab | null | undefined): string {
if (tab === followersTab) return 'ph-user ph-bold ph-lg';
if (tab === mutualsTab) return 'ph-user-switch ph-bold ph-lg';
return 'ph-user-check ph-bold ph-lg';
}
export type FollowingFeedModel = {
[Key in keyof FollowingFeedState]: WritableComputedRef<FollowingFeedState[Key]>;
}
export interface FollowingFeedState {
withNonPublic: boolean,
withQuotes: boolean,
withBots: boolean,
withReplies: boolean,
onlyFiles: boolean,
userList: FollowingFeedTab,
remoteWarningDismissed: boolean,
}
export const defaultFollowingFeedState: FollowingFeedState = {
withNonPublic: false,
withQuotes: false,
withBots: true,
withReplies: false,
onlyFiles: false,
userList: followingTab,
remoteWarningDismissed: false,
};
interface StorageInterface<T extends Partial<FollowingFeedState> = Partial<FollowingFeedState>> {
readonly state: Partial<T>;
readonly reactiveState: Ref<Partial<T>>;
save(updated: T): void;
}
export function createHeaderItem(storage?: Ref<StorageInterface>): PageHeaderItem {
const menu = createOptionsMenu(storage);
return {
icon: 'ti ti-dots',
text: i18n.ts.options,
handler: ev => popupMenu(menu, ev.currentTarget ?? ev.target),
};
}
export function createOptionsMenu(storage?: Ref<StorageInterface>): MenuItem[] {
const { const {
userList, userList,
withNonPublic, withNonPublic,
@ -23,80 +79,83 @@ export function createOptions(): PageHeaderItem {
withBots, withBots,
withReplies, withReplies,
onlyFiles, onlyFiles,
} = createModel(); } = createModel(storage);
return { return [
icon: 'ti ti-dots', {
text: i18n.ts.options, type: 'switch',
handler: ev => text: i18n.ts.showNonPublicNotes,
popupMenu([ ref: withNonPublic,
{ disabled: computed(() => userList.value === followersTab),
type: 'switch', },
text: i18n.ts.showNonPublicNotes, {
ref: withNonPublic, type: 'switch',
disabled: userList.value === 'followers', text: i18n.ts.showQuotes,
}, ref: withQuotes,
{ },
type: 'switch', {
text: i18n.ts.showQuotes, type: 'switch',
ref: withQuotes, text: i18n.ts.showBots,
}, ref: withBots,
{ },
type: 'switch', {
text: i18n.ts.showBots, type: 'switch',
ref: withBots, text: i18n.ts.showReplies,
}, ref: withReplies,
{ disabled: onlyFiles,
type: 'switch', },
text: i18n.ts.showReplies, {
ref: withReplies, type: 'divider',
disabled: onlyFiles, },
}, {
{ type: 'switch',
type: 'divider', text: i18n.ts.fileAttachedOnly,
}, ref: onlyFiles,
{ disabled: withReplies,
type: 'switch', },
text: i18n.ts.fileAttachedOnly, ];
ref: onlyFiles,
disabled: withReplies,
},
], ev.currentTarget ?? ev.target),
};
} }
export function createModel() { export function createModel(storage?: Ref<StorageInterface>): FollowingFeedModel {
const userList = computed({ // eslint-disable-next-line no-param-reassign
get: () => defaultStore.reactiveState.followingFeed.value.userList, storage ??= createDefaultStorage();
// Based on timeline.saveTlFilter()
const saveFollowingFilter = <K extends keyof FollowingFeedState>(key: K, value: FollowingFeedState[K]) => {
const state = deepMerge(storage.value.state, defaultFollowingFeedState);
const out = deepMerge({ [key]: value }, state);
storage.value.save(out);
};
const userList: WritableComputedRef<FollowingFeedTab> = computed({
get: () => storage.value.reactiveState.value.userList ?? defaultFollowingFeedState.userList,
set: value => saveFollowingFilter('userList', value), set: value => saveFollowingFilter('userList', value),
}); });
const withNonPublic: WritableComputedRef<boolean> = computed({
const withNonPublic = computed({
get: () => { get: () => {
if (userList.value === 'followers') return false; if (userList.value === 'followers') return false;
return defaultStore.reactiveState.followingFeed.value.withNonPublic; return storage.value.reactiveState.value.withNonPublic ?? defaultFollowingFeedState.withNonPublic;
}, },
set: value => saveFollowingFilter('withNonPublic', value), set: value => saveFollowingFilter('withNonPublic', value),
}); });
const withQuotes = computed({ const withQuotes: WritableComputedRef<boolean> = computed({
get: () => defaultStore.reactiveState.followingFeed.value.withQuotes, get: () => storage.value.reactiveState.value.withQuotes ?? defaultFollowingFeedState.withQuotes,
set: value => saveFollowingFilter('withQuotes', value), set: value => saveFollowingFilter('withQuotes', value),
}); });
const withBots = computed({ const withBots: WritableComputedRef<boolean> = computed({
get: () => defaultStore.reactiveState.followingFeed.value.withBots, get: () => storage.value.reactiveState.value.withBots ?? defaultFollowingFeedState.withBots,
set: value => saveFollowingFilter('withBots', value), set: value => saveFollowingFilter('withBots', value),
}); });
const withReplies = computed({ const withReplies: WritableComputedRef<boolean> = computed({
get: () => defaultStore.reactiveState.followingFeed.value.withReplies, get: () => storage.value.reactiveState.value.withReplies ?? defaultFollowingFeedState.withReplies,
set: value => saveFollowingFilter('withReplies', value), set: value => saveFollowingFilter('withReplies', value),
}); });
const onlyFiles = computed({ const onlyFiles: WritableComputedRef<boolean> = computed({
get: () => defaultStore.reactiveState.followingFeed.value.onlyFiles, get: () => storage.value.reactiveState.value.onlyFiles ?? defaultFollowingFeedState.onlyFiles,
set: value => saveFollowingFilter('onlyFiles', value), set: value => saveFollowingFilter('onlyFiles', value),
}); });
const remoteWarningDismissed: WritableComputedRef<boolean> = computed({
const remoteWarningDismissed = computed({ get: () => storage.value.reactiveState.value.remoteWarningDismissed ?? defaultFollowingFeedState.remoteWarningDismissed,
get: () => defaultStore.reactiveState.followingFeed.value.remoteWarningDismissed,
set: value => saveFollowingFilter('remoteWarningDismissed', value), set: value => saveFollowingFilter('remoteWarningDismissed', value),
}); });
@ -111,8 +170,12 @@ export function createModel() {
}; };
} }
// Based on timeline.saveTlFilter() function createDefaultStorage() {
function saveFollowingFilter<Key extends keyof typeof defaultStore.state.followingFeed>(key: Key, value: (typeof defaultStore.state.followingFeed)[Key]) { return computed(() => ({
const out = deepMerge({ [key]: value }, defaultStore.state.followingFeed); state: defaultStore.state.followingFeed,
return defaultStore.set('followingFeed', out); reactiveState: defaultStore.reactiveState.followingFeed,
save(updated: typeof defaultStore.state.followingFeed) {
return defaultStore.set('followingFeed', updated);
},
}));
} }

View File

@ -18,7 +18,7 @@ function isPureObject(value: unknown): value is Record<string | number | symbol,
* valueにないキーをdefからもらう\ * valueにないキーをdefからもらう\
* nullはそのままundefinedはdefの値 * nullはそのままundefinedはdefの値
**/ **/
export function deepMerge<X extends Record<string | number | symbol, unknown>>(value: DeepPartial<X>, def: X): X { export function deepMerge<X extends object>(value: DeepPartial<X>, def: X): X {
if (isPureObject(value) && isPureObject(def)) { if (isPureObject(value) && isPureObject(def)) {
const result = deepClone(value as Cloneable) as X; const result = deepClone(value as Cloneable) as X;
for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) { for (const [k, v] of Object.entries(def) as [keyof X, X[keyof X]][]) {

View File

@ -11,7 +11,7 @@ import darkTheme from '@@/themes/d-ice.json5';
import { miLocalStorage } from './local-storage.js'; import { miLocalStorage } from './local-storage.js';
import { searchEngineMap } from './scripts/search-engine-map.js'; import { searchEngineMap } from './scripts/search-engine-map.js';
import type { SoundType } from '@/scripts/sound.js'; import type { SoundType } from '@/scripts/sound.js';
import type { FollowingFeedTab } from '@/scripts/following-feed-utils.js'; import { defaultFollowingFeedState } from '@/scripts/following-feed-utils.js';
import { Storage } from '@/pizzax.js'; import { Storage } from '@/pizzax.js';
interface PostFormAction { interface PostFormAction {
@ -244,15 +244,7 @@ export const defaultStore = markRaw(new Storage('base', {
}, },
followingFeed: { followingFeed: {
where: 'account', where: 'account',
default: { default: defaultFollowingFeedState,
withNonPublic: false,
withQuotes: false,
withBots: true,
withReplies: false,
onlyFiles: false,
userList: 'following' as FollowingFeedTab,
remoteWarningDismissed: false,
},
}, },
overridedDeviceKind: { overridedDeviceKind: {

View File

@ -18,16 +18,21 @@ SPDX-License-Identifier: AGPL-3.0-only
:style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }" :style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }"
@wheel.self="onWheel" @wheel.self="onWheel"
> >
<component <Suspense>
:is="columnComponents[columns.find(c => c.id === id)!.type] ?? XTlColumn" <component
v-for="id in ids" :is="columnComponents[columns.find(c => c.id === id)!.type] ?? XTlColumn"
:ref="id" v-for="id in ids"
:key="id" :ref="id"
:class="$style.column" :key="id"
:column="columns.find(c => c.id === id)!" :class="$style.column"
:isStacked="ids.length > 1" :column="columns.find(c => c.id === id)!"
@headerWheel="onWheel" :isStacked="ids.length > 1"
/> @headerWheel="onWheel"
/>
<template #fallback>
<MkLoading/>
</template>
</Suspense>
</section> </section>
<div v-if="layout.length === 0" class="_panel" :class="$style.onboarding"> <div v-if="layout.length === 0" class="_panel" :class="$style.onboarding">
<div>{{ i18n.ts._deck.introduction }}</div> <div>{{ i18n.ts._deck.introduction }}</div>
@ -117,6 +122,7 @@ import XWidgetsColumn from '@/ui/deck/widgets-column.vue';
import XMentionsColumn from '@/ui/deck/mentions-column.vue'; import XMentionsColumn from '@/ui/deck/mentions-column.vue';
import XDirectColumn from '@/ui/deck/direct-column.vue'; import XDirectColumn from '@/ui/deck/direct-column.vue';
import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue'; import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue';
import XFollowingColumn from '@/ui/deck/following-column.vue';
import { mainRouter } from '@/router/main.js'; import { mainRouter } from '@/router/main.js';
import type { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue'));
@ -133,6 +139,7 @@ const columnComponents = {
mentions: XMentionsColumn, mentions: XMentionsColumn,
direct: XDirectColumn, direct: XDirectColumn,
roleTimeline: XRoleTimelineColumn, roleTimeline: XRoleTimelineColumn,
following: XFollowingColumn,
}; };
mainRouter.navHook = (path, flag): boolean => { mainRouter.navHook = (path, flag): boolean => {

View File

@ -4,7 +4,7 @@
*/ */
import { throttle } from 'throttle-debounce'; import { throttle } from 'throttle-debounce';
import { markRaw } from 'vue'; import { computed, markRaw, Ref } from 'vue';
import { notificationTypes } from 'misskey-js'; import { notificationTypes } from 'misskey-js';
import type { BasicTimelineType } from '@/timelines.js'; import type { BasicTimelineType } from '@/timelines.js';
import { Storage } from '@/pizzax.js'; import { Storage } from '@/pizzax.js';
@ -29,6 +29,7 @@ export const columnTypes = [
'mentions', 'mentions',
'direct', 'direct',
'roleTimeline', 'roleTimeline',
'following',
] as const; ] as const;
export type ColumnType = typeof columnTypes[number]; export type ColumnType = typeof columnTypes[number];
@ -112,8 +113,8 @@ export const loadDeck = async () => {
}; };
// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する // TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する
export const saveDeck = throttle(1000, () => { export const saveDeck = throttle(1000, async () => {
misskeyApi('i/registry/set', { await misskeyApi('i/registry/set', {
scope: ['client', 'deck', 'profiles'], scope: ['client', 'deck', 'profiles'],
key: deckStore.state.profile, key: deckStore.state.profile,
value: { value: {
@ -313,7 +314,7 @@ export function updateColumnWidget(id: Column['id'], widgetId: string, widgetDat
saveDeck(); saveDeck();
} }
export function updateColumn(id: Column['id'], column: Partial<Column>) { export async function updateColumn<TColumn>(id: Column['id'], column: Partial<TColumn>) {
const columns = deepClone(deckStore.state.columns); const columns = deepClone(deckStore.state.columns);
const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
const currentColumn = deepClone(deckStore.state.columns[columnIndex]); const currentColumn = deepClone(deckStore.state.columns[columnIndex]);
@ -322,6 +323,18 @@ export function updateColumn(id: Column['id'], column: Partial<Column>) {
currentColumn[k] = v; currentColumn[k] = v;
} }
columns[columnIndex] = currentColumn; columns[columnIndex] = currentColumn;
deckStore.set('columns', columns); await Promise.all([
saveDeck(); deckStore.set('columns', columns),
saveDeck(),
]);
}
export function getColumn<TColumn extends Column>(id: Column['id']): TColumn {
return deckStore.state.columns.find(c => c.id === id) as TColumn;
}
export function getReactiveColumn<TColumn extends Column>(id: Column['id']): Ref<TColumn> {
return computed(() => {
return deckStore.reactiveState.columns.value.find(c => c.id === id) as TColumn;
});
} }

View File

@ -0,0 +1,124 @@
<!--
SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<!-- based on list-column.vue -->
<template>
<XColumn :menu="menu" :column="column" :isStacked="isStacked" :refresher="reload">
<template #header>
<i :class="columnIcon" aria-hidden="true"/><span style="margin-left: 8px;">{{ column.name }}</span>
</template>
<SkRemoteFollowersWarning :class="$style.followersWarning" :model="model"/>
<SkFollowingRecentNotes ref="latestNotes" :userList="userList" :withNonPublic="withNonPublic" :withQuotes="withQuotes" :withReplies="withReplies" :withBots="withBots" :onlyFiles="onlyFiles" @userSelected="userSelected"/>
</XColumn>
</template>
<script lang="ts">
import { computed, shallowRef } from 'vue';
import type { Column } from '@/ui/deck/deck-store.js';
import type { FollowingFeedState } from '@/scripts/following-feed-utils.js';
export type FollowingColumn = Column & Partial<FollowingFeedState>;
</script>
<script setup lang="ts">
import { getColumn, getReactiveColumn, updateColumn } from '@/ui/deck/deck-store.js';
import XColumn from '@/ui/deck/column.vue';
import SkFollowingRecentNotes from '@/components/SkFollowingRecentNotes.vue';
import SkRemoteFollowersWarning from '@/components/SkRemoteFollowersWarning.vue';
import { createModel, createOptionsMenu, FollowingFeedTab, followingTab, followingTabName, followingTabIcon, followingFeedTabs } from '@/scripts/following-feed-utils.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { MenuItem } from '@/types/menu.js';
import { useRouter } from '@/router/supplier.js';
const props = defineProps<{
column: FollowingColumn;
isStacked: boolean;
}>();
const columnIcon = computed(() => followingTabIcon(props.column.userList));
async function selectList(): Promise<void> {
const { canceled, result: newList } = await os.select<FollowingFeedTab>({
title: i18n.ts.selectFollowRelationship,
items: followingFeedTabs.map(t => ({
value: t,
text: followingTabName(t),
})),
default: props.column.userList ?? followingTab,
});
if (canceled) return;
await updateColumn(props.column.id, {
name: getNewColumnName(newList),
userList: newList,
});
}
function getNewColumnName(newList: FollowingFeedTab) {
// If the user has renamed the column, then we need to keep that name.
// If no list is specified, then the column is newly created and the user *can't* have renamed it.
if (props.column.userList && props.column.name === followingTabName(props.column.userList)) {
return props.column.name;
}
// Otherwise, we should match the name to the selected list.
return followingTabName(newList);
}
if (!props.column.userList) {
await selectList();
}
// Redirects the Following Feed logic into column-specific storage.
// This allows multiple columns to exist with different settings.
const columnStorage = computed(() => ({
state: getColumn<FollowingColumn>(props.column.id),
reactiveState: getReactiveColumn<FollowingColumn>(props.column.id),
save(updated: FollowingColumn) {
updateColumn(props.column.id, updated);
},
}));
const model = createModel(columnStorage);
const {
userList,
withNonPublic,
withQuotes,
withReplies,
withBots,
onlyFiles,
} = model;
const menu: MenuItem[] = [
{
icon: columnIcon.value,
text: i18n.ts.selectFollowRelationship,
action: selectList,
},
...createOptionsMenu(columnStorage),
];
const latestNotes = shallowRef<InstanceType<typeof SkFollowingRecentNotes>>();
async function reload() {
await latestNotes.value?.reload();
}
const router = useRouter();
function userSelected(userId: string) {
router.push(`/following-feed/${userId}`);
}
</script>
<style lang="scss" module>
.followersWarning {
margin-bottom: 8px;
border-radius: 0;
}
</style>

View File

@ -397,3 +397,8 @@ _auth:
allowed: "Allowed" allowed: "Allowed"
_announcement: _announcement:
new: "New" new: "New"
_deck:
_columns:
following: "Following"
selectFollowRelationship: "Select a follow relationship..."