-
+
@@ -25,7 +25,9 @@ SPDX-License-Identifier: AGPL-3.0-only
diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts
index 2c8d21a9d3..b0ac14f022 100644
--- a/packages/frontend/src/router/definition.ts
+++ b/packages/frontend/src/router/definition.ts
@@ -232,6 +232,9 @@ const routes: RouteDef[] = [{
component: page(() => import('@/pages/search.vue')),
query: {
q: 'query',
+ userId: 'userId',
+ username: 'username',
+ host: 'host',
channel: 'channel',
type: 'type',
origin: 'origin',
diff --git a/packages/frontend/src/scripts/check-permissions.ts b/packages/frontend/src/scripts/check-permissions.ts
new file mode 100644
index 0000000000..ed86529d5b
--- /dev/null
+++ b/packages/frontend/src/scripts/check-permissions.ts
@@ -0,0 +1,19 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { instance } from '@/instance.js';
+import { $i } from '@/account.js';
+
+export const notesSearchAvailable = (
+ // FIXME: instance.policies would be null in Vitest
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ ($i == null && instance.policies != null && instance.policies.canSearchNotes) ||
+ ($i != null && $i.policies.canSearchNotes) ||
+ false
+) as boolean;
+
+export const canSearchNonLocalNotes = (
+ instance.noteSearchableScope === 'global'
+);
diff --git a/packages/frontend/src/scripts/get-drive-file-menu.ts b/packages/frontend/src/scripts/get-drive-file-menu.ts
index 7c6c4b4db4..108648d640 100644
--- a/packages/frontend/src/scripts/get-drive-file-menu.ts
+++ b/packages/frontend/src/scripts/get-drive-file-menu.ts
@@ -41,6 +41,15 @@ function describe(file: Misskey.entities.DriveFile) {
});
}
+function move(file: Misskey.entities.DriveFile) {
+ os.selectDriveFolder(false).then(folder => {
+ misskeyApi('drive/files/update', {
+ fileId: file.id,
+ folderId: folder[0] ? folder[0].id : null,
+ });
+ });
+}
+
function toggleSensitive(file: Misskey.entities.DriveFile) {
misskeyApi('drive/files/update', {
fileId: file.id,
@@ -88,6 +97,10 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
text: i18n.ts.rename,
icon: 'ti ti-forms',
action: () => rename(file),
+ }, {
+ text: i18n.ts.move,
+ icon: 'ti ti-folder-symlink',
+ action: () => move(file),
}, {
text: file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
icon: file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-exclamation',
diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts
index dacafb859f..33f16a68aa 100644
--- a/packages/frontend/src/scripts/get-user-menu.ts
+++ b/packages/frontend/src/scripts/get-user-menu.ts
@@ -13,9 +13,11 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
import { defaultStore, userActions } from '@/store.js';
import { $i, iAmModerator } from '@/account.js';
+import { notesSearchAvailable, canSearchNonLocalNotes } from '@/scripts/check-permissions.js';
import { IRouter } from '@/nirax.js';
import { antennasCache, rolesCache, userListsCache } from '@/cache.js';
import { mainRouter } from '@/router/main.js';
+import { MenuItem } from '@/types/menu.js';
export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter = mainRouter) {
const meId = $i ? $i.id : null;
@@ -81,15 +83,6 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
});
}
- async function toggleWithReplies() {
- os.apiWithDialog('following/update', {
- userId: user.id,
- withReplies: !user.withReplies,
- }).then(() => {
- user.withReplies = !user.withReplies;
- });
- }
-
async function toggleNotify() {
os.apiWithDialog('following/update', {
userId: user.id,
@@ -154,13 +147,20 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
});
}
- let menu = [{
+ let menu: MenuItem[] = [{
icon: 'ti ti-at',
text: i18n.ts.copyUsername,
action: () => {
copyToClipboard(`@${user.username}@${user.host ?? host}`);
},
- }, ...(iAmModerator ? [{
+ }, ...( notesSearchAvailable && (user.host == null || canSearchNonLocalNotes) ? [{
+ icon: 'ti ti-search',
+ text: i18n.ts.searchThisUsersNotes,
+ action: () => {
+ router.push(`/search?username=${encodeURIComponent(user.username)}${user.host != null ? '&host=' + encodeURIComponent(user.host) : ''}`);
+ },
+ }] : [])
+ , ...(iAmModerator ? [{
icon: 'ti ti-user-exclamation',
text: i18n.ts.moderation,
action: () => {
@@ -306,15 +306,25 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: IRouter
// フォローしたとしても user.isFollowing はリアルタイム更新されないので不便なため
//if (user.isFollowing) {
+ const withRepliesRef = ref(user.withReplies);
menu = menu.concat([{
- icon: user.withReplies ? 'ti ti-messages-off' : 'ti ti-messages',
- text: user.withReplies ? i18n.ts.hideRepliesToOthersInTimeline : i18n.ts.showRepliesToOthersInTimeline,
- action: toggleWithReplies,
+ type: 'switch',
+ icon: 'ti ti-messages',
+ text: i18n.ts.showRepliesToOthersInTimeline,
+ ref: withRepliesRef,
}, {
icon: user.notify === 'none' ? 'ti ti-bell' : 'ti ti-bell-off',
text: user.notify === 'none' ? i18n.ts.notifyNotes : i18n.ts.unnotifyNotes,
action: toggleNotify,
}]);
+ watch(withRepliesRef, (withReplies) => {
+ misskeyApi('following/update', {
+ userId: user.id,
+ withReplies,
+ }).then(() => {
+ user.withReplies = withReplies;
+ });
+ });
//}
menu = menu.concat([{ type: 'divider' }, {
diff --git a/packages/frontend/src/scripts/isFfVisibleForMe.ts b/packages/frontend/src/scripts/isFfVisibleForMe.ts
index 406404c462..e28e5725bc 100644
--- a/packages/frontend/src/scripts/isFfVisibleForMe.ts
+++ b/packages/frontend/src/scripts/isFfVisibleForMe.ts
@@ -7,7 +7,7 @@ import * as Misskey from 'misskey-js';
import { $i } from '@/account.js';
export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
- if ($i && $i.id === user.id) return true;
+ if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true;
if (user.followingVisibility === 'private') return false;
if (user.followingVisibility === 'followers' && !user.isFollowing) return false;
@@ -15,7 +15,7 @@ export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): bo
return true;
}
export function isFollowersVisibleForMe(user: Misskey.entities.UserDetailed): boolean {
- if ($i && $i.id === user.id) return true;
+ if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true;
if (user.followersVisibility === 'private') return false;
if (user.followersVisibility === 'followers' && !user.isFollowing) return false;
diff --git a/packages/frontend/src/scripts/lookup.ts b/packages/frontend/src/scripts/lookup.ts
index 7f020b15cc..a261ec0669 100644
--- a/packages/frontend/src/scripts/lookup.ts
+++ b/packages/frontend/src/scripts/lookup.ts
@@ -16,7 +16,7 @@ export async function lookup(router?: Router) {
title: i18n.ts.lookup,
});
const query = temp ? temp.trim() : '';
- if (canceled) return;
+ if (canceled || query.length <= 1) return;
if (query.startsWith('@') && !query.includes(' ')) {
_router.push(`/${query}`);
diff --git a/packages/frontend/src/scripts/merge.ts b/packages/frontend/src/scripts/merge.ts
index 4e39a0fa06..9794a300da 100644
--- a/packages/frontend/src/scripts/merge.ts
+++ b/packages/frontend/src/scripts/merge.ts
@@ -6,7 +6,7 @@
import { deepClone } from './clone.js';
import type { Cloneable } from './clone.js';
-type DeepPartial = {
+export type DeepPartial = {
[P in keyof T]?: T[P] extends Record ? DeepPartial : T[P];
};
diff --git a/packages/frontend/src/scripts/sound.ts b/packages/frontend/src/scripts/sound.ts
index bba855cd64..05f82fce7d 100644
--- a/packages/frontend/src/scripts/sound.ts
+++ b/packages/frontend/src/scripts/sound.ts
@@ -124,14 +124,16 @@ export async function loadAudio(url: string, options?: { useCache?: boolean; })
*/
export function playMisskeySfx(operationType: OperationType) {
const sound = defaultStore.state[`sound_${operationType}`];
- if (sound.type == null || !canPlay || ('userActivation' in navigator && !navigator.userActivation.hasBeenActive)) return;
-
- canPlay = false;
- playMisskeySfxFile(sound).finally(() => {
- // ごく短時間に音が重複しないように
- setTimeout(() => {
- canPlay = true;
- }, 25);
+ playMisskeySfxFile(sound).then((succeed) => {
+ if (!succeed && sound.type === '_driveFile_') {
+ // ドライブファイルが存在しない場合はデフォルトのサウンドを再生する
+ const soundName = defaultStore.def[`sound_${operationType}`].default.type as Exclude;
+ if (_DEV_) console.log(`Failed to play sound: ${sound.fileUrl}, so play default sound: ${soundName}`);
+ playMisskeySfxFileInternal({
+ type: soundName,
+ volume: sound.volume,
+ });
+ }
});
}
@@ -139,19 +141,39 @@ export function playMisskeySfx(operationType: OperationType) {
* サウンド設定形式で指定された音声を再生する
* @param soundStore サウンド設定
*/
-export async function playMisskeySfxFile(soundStore: SoundStore) {
+export async function playMisskeySfxFile(soundStore: SoundStore): Promise {
+ // 連続して再生しない
+ if (!canPlay) return false;
+ // ユーザーアクティベーションが必要な場合はそれがない場合は再生しない
+ if ('userActivation' in navigator && !navigator.userActivation.hasBeenActive) return false;
+ // サウンドがない場合は再生しない
+ if (soundStore.type === null || soundStore.type === '_driveFile_' && !soundStore.fileUrl) return false;
+
+ canPlay = false;
+ return await playMisskeySfxFileInternal(soundStore).finally(() => {
+ // ごく短時間に音が重複しないように
+ setTimeout(() => {
+ canPlay = true;
+ }, 25);
+ });
+}
+
+async function playMisskeySfxFileInternal(soundStore: SoundStore): Promise {
if (soundStore.type === null || (soundStore.type === '_driveFile_' && !soundStore.fileUrl)) {
- return;
+ return false;
}
const masterVolume = defaultStore.state.sound_masterVolume;
if (isMute() || masterVolume === 0 || soundStore.volume === 0) {
- return;
+ return true; // ミュート時は成功として扱う
}
const url = soundStore.type === '_driveFile_' ? soundStore.fileUrl : `/client-assets/sounds/${soundStore.type}.mp3`;
- const buffer = await loadAudio(url);
- if (!buffer) return;
+ const buffer = await loadAudio(url).catch(() => {
+ return undefined;
+ });
+ if (!buffer) return false;
const volume = soundStore.volume * masterVolume;
createSourceNode(buffer, { volume }).soundSource.start();
+ return true;
}
export async function playUrl(url: string, opts: {
diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts
index dbf6b8716f..437314074a 100644
--- a/packages/frontend/src/store.ts
+++ b/packages/frontend/src/store.ts
@@ -458,6 +458,10 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'device',
default: false,
},
+ contextMenu: {
+ where: 'device',
+ default: 'app' as 'app' | 'appWithShift' | 'native',
+ },
sound_masterVolume: {
where: 'device',
diff --git a/packages/frontend/src/timelines.ts b/packages/frontend/src/timelines.ts
new file mode 100644
index 0000000000..94eda3545e
--- /dev/null
+++ b/packages/frontend/src/timelines.ts
@@ -0,0 +1,56 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { $i } from '@/account.js';
+import { instance } from '@/instance.js';
+
+export const basicTimelineTypes = [
+ 'home',
+ 'local',
+ 'social',
+ 'global',
+] as const;
+
+export type BasicTimelineType = typeof basicTimelineTypes[number];
+
+export function isBasicTimeline(timeline: string): timeline is BasicTimelineType {
+ return basicTimelineTypes.includes(timeline as BasicTimelineType);
+}
+
+export function basicTimelineIconClass(timeline: BasicTimelineType): string {
+ switch (timeline) {
+ case 'home':
+ return 'ti ti-home';
+ case 'local':
+ return 'ti ti-planet';
+ case 'social':
+ return 'ti ti-universe';
+ case 'global':
+ return 'ti ti-whirl';
+ }
+}
+
+export function isAvailableBasicTimeline(timeline: BasicTimelineType | undefined | null): boolean {
+ switch (timeline) {
+ case 'home':
+ return $i != null;
+ case 'local':
+ return ($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable);
+ case 'social':
+ return $i != null && $i.policies.ltlAvailable;
+ case 'global':
+ return ($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable);
+ default:
+ return false;
+ }
+}
+
+export function availableBasicTimelines(): BasicTimelineType[] {
+ return basicTimelineTypes.filter(isAvailableBasicTimeline);
+}
+
+export function hasWithReplies(timeline: BasicTimelineType | undefined | null): boolean {
+ return timeline === 'local' || timeline === 'social';
+}
diff --git a/packages/frontend/src/ui/deck.vue b/packages/frontend/src/ui/deck.vue
index bdb62dca15..af46b0641d 100644
--- a/packages/frontend/src/ui/deck.vue
+++ b/packages/frontend/src/ui/deck.vue
@@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:ref="id"
:key="id"
:class="$style.column"
- :column="columns.find(c => c.id === id)"
+ :column="columns.find(c => c.id === id)!"
:isStacked="ids.length > 1"
@headerWheel="onWheel"
/>
@@ -95,7 +95,8 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, defineAsyncComponent, ref, watch, shallowRef } from 'vue';
import { v4 as uuid } from 'uuid';
import XCommon from './_common_/common.vue';
-import { deckStore, addColumn as addColumnToStore, loadDeck, getProfiles, deleteProfile as deleteProfile_ } from './deck/deck-store.js';
+import { deckStore, columnTypes, addColumn as addColumnToStore, loadDeck, getProfiles, deleteProfile as deleteProfile_ } from './deck/deck-store.js';
+import type { ColumnType } from './deck/deck-store.js';
import XSidebar from '@/ui/_common_/navbar.vue';
import XDrawerMenu from '@/ui/_common_/navbar-for-mobile.vue';
import MkButton from '@/components/MkButton.vue';
@@ -152,10 +153,12 @@ window.addEventListener('resize', () => {
const snapScroll = deviceKind === 'smartphone' || deviceKind === 'tablet';
const drawerMenuShowing = ref(false);
+/*
const route = 'TODO';
watch(route, () => {
drawerMenuShowing.value = false;
});
+*/
const columns = deckStore.reactiveState.columns;
const layout = deckStore.reactiveState.layout;
@@ -174,32 +177,20 @@ function showSettings() {
const columnsEl = shallowRef();
const addColumn = async (ev) => {
- const columns = [
- 'main',
- 'widgets',
- 'notifications',
- 'tl',
- 'antenna',
- 'list',
- 'channel',
- 'mentions',
- 'direct',
- 'roleTimeline',
- ];
-
const { canceled, result: column } = await os.select({
title: i18n.ts._deck.addColumn,
- items: columns.map(column => ({
+ items: columnTypes.map(column => ({
value: column, text: i18n.ts._deck._columns[column],
})),
});
- if (canceled) return;
+ if (canceled || column == null) return;
addColumnToStore({
type: column,
id: uuid(),
name: i18n.ts._deck._columns[column],
width: 330,
+ soundSetting: { type: null, volume: 1 },
});
};
@@ -211,7 +202,7 @@ const onContextmenu = (ev) => {
};
function onWheel(ev: WheelEvent) {
- if (ev.deltaX === 0) {
+ if (ev.deltaX === 0 && columnsEl.value != null) {
columnsEl.value.scrollLeft += ev.deltaY;
}
}
@@ -242,7 +233,7 @@ function changeProfile(ev: MouseEvent) {
title: i18n.ts._deck.profile,
minLength: 1,
});
- if (canceled) return;
+ if (canceled || name == null) return;
deckStore.set('profile', name);
unisonReload();
diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue
index c3dc1e4fce..987bd4db55 100644
--- a/packages/frontend/src/ui/deck/antenna-column.vue
+++ b/packages/frontend/src/ui/deck/antenna-column.vue
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
-
+
{{ column.name }}
@@ -14,7 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only