Compare commits

...

6 Commits

Author SHA1 Message Date
syuilo c106db89e1 feat: note edit 2023-09-28 17:21:16 +09:00
syuilo a388e25f3e Update CHANGELOG.md 2023-09-28 15:35:21 +09:00
taichan 63c6a9bb80
Feat: register_post_form_actionでcwを変更可能にする (#11911)
* 投稿フォームのアクション追加するプラグインでCWを変更可能にする

* Update CHANGELOG
2023-09-28 15:35:00 +09:00
syuilo 772d2432b6 enhance: タイムラインからRenoteを除外するオプションを追加 2023-09-28 15:32:47 +09:00
syuilo eb740e2c72 enhance: タイムラインからRenoteを除外するオプションを追加 2023-09-28 11:41:41 +09:00
syuilo d854942a1f .js 2023-09-28 11:04:14 +09:00
34 changed files with 338 additions and 34 deletions

View File

@ -15,10 +15,14 @@
## next
### General
- Feat: ノートの編集をできるように
- ロールで編集可否を設定可能
- Enhance: タイムラインからRenoteを除外するオプションを追加
- Enhance: ユーザーページのート一覧でRenoteを除外できるように
### Client
- Enhance: モデレーションログ機能の強化
- Enhance: Plugin:register_post_form_actionを用いてCWを取得・変更できるように
### Server
- Enhance: MasterプロセスのPIDを書き出せるように

2
locales/index.d.ts vendored
View File

@ -1124,6 +1124,7 @@ export interface Locale {
"authentication": string;
"authenticationRequiredToContinue": string;
"dateAndTime": string;
"showRenotes": string;
"_announcement": {
"forExistingUsers": string;
"forExistingUsersDescription": string;
@ -1537,6 +1538,7 @@ export interface Locale {
"gtlAvailable": string;
"ltlAvailable": string;
"canPublicNote": string;
"canEditNote": string;
"canInvite": string;
"inviteLimit": string;
"inviteLimitCycle": string;

View File

@ -1121,6 +1121,7 @@ unnotifyNotes: "投稿の通知を解除"
authentication: "認証"
authenticationRequiredToContinue: "続けるには認証を行ってください"
dateAndTime: "日時"
showRenotes: "リノートを表示"
_announcement:
forExistingUsers: "既存ユーザーのみ"
@ -1458,6 +1459,7 @@ _role:
gtlAvailable: "グローバルタイムラインの閲覧"
ltlAvailable: "ローカルタイムラインの閲覧"
canPublicNote: "パブリック投稿の許可"
canEditNote: "ノートの編集"
canInvite: "サーバー招待コードの発行"
inviteLimit: "招待コードの作成可能数"
inviteLimitCycle: "招待コードの発行間隔"

View File

@ -26,6 +26,7 @@ export type RolePolicies = {
gtlAvailable: boolean;
ltlAvailable: boolean;
canPublicNote: boolean;
canEditNote: boolean;
canInvite: boolean;
inviteLimit: number;
inviteLimitCycle: number;
@ -50,6 +51,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
gtlAvailable: true,
ltlAvailable: true,
canPublicNote: true,
canEditNote: true,
canInvite: false,
inviteLimit: 0,
inviteLimitCycle: 60 * 24 * 7,
@ -294,6 +296,7 @@ export class RoleService implements OnApplicationShutdown {
gtlAvailable: calc('gtlAvailable', vs => vs.some(v => v === true)),
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
canEditNote: calc('canEditNote', vs => vs.some(v => v === true)),
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),

View File

@ -258,6 +258,7 @@ import * as ep___notes_clips from './endpoints/notes/clips.js';
import * as ep___notes_conversation from './endpoints/notes/conversation.js';
import * as ep___notes_create from './endpoints/notes/create.js';
import * as ep___notes_delete from './endpoints/notes/delete.js';
import * as ep___notes_update from './endpoints/notes/update.js';
import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js';
import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js';
import * as ep___notes_featured from './endpoints/notes/featured.js';
@ -606,6 +607,7 @@ const $notes_clips: Provider = { provide: 'ep:notes/clips', useClass: ep___notes
const $notes_conversation: Provider = { provide: 'ep:notes/conversation', useClass: ep___notes_conversation.default };
const $notes_create: Provider = { provide: 'ep:notes/create', useClass: ep___notes_create.default };
const $notes_delete: Provider = { provide: 'ep:notes/delete', useClass: ep___notes_delete.default };
const $notes_update: Provider = { provide: 'ep:notes/update', useClass: ep___notes_update.default };
const $notes_favorites_create: Provider = { provide: 'ep:notes/favorites/create', useClass: ep___notes_favorites_create.default };
const $notes_favorites_delete: Provider = { provide: 'ep:notes/favorites/delete', useClass: ep___notes_favorites_delete.default };
const $notes_featured: Provider = { provide: 'ep:notes/featured', useClass: ep___notes_featured.default };
@ -958,6 +960,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$notes_conversation,
$notes_create,
$notes_delete,
$notes_update,
$notes_favorites_create,
$notes_favorites_delete,
$notes_featured,
@ -1304,6 +1307,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$notes_conversation,
$notes_create,
$notes_delete,
$notes_update,
$notes_favorites_create,
$notes_favorites_delete,
$notes_featured,

View File

@ -258,6 +258,7 @@ import * as ep___notes_clips from './endpoints/notes/clips.js';
import * as ep___notes_conversation from './endpoints/notes/conversation.js';
import * as ep___notes_create from './endpoints/notes/create.js';
import * as ep___notes_delete from './endpoints/notes/delete.js';
import * as ep___notes_update from './endpoints/notes/update.js';
import * as ep___notes_favorites_create from './endpoints/notes/favorites/create.js';
import * as ep___notes_favorites_delete from './endpoints/notes/favorites/delete.js';
import * as ep___notes_featured from './endpoints/notes/featured.js';
@ -604,6 +605,7 @@ const eps = [
['notes/conversation', ep___notes_conversation],
['notes/create', ep___notes_create],
['notes/delete', ep___notes_delete],
['notes/update', ep___notes_update],
['notes/favorites/create', ep___notes_favorites_create],
['notes/favorites/delete', ep___notes_favorites_delete],
['notes/featured', ep___notes_featured],

View File

@ -4,6 +4,7 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import type { NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
@ -40,6 +41,7 @@ export const paramDef = {
properties: {
withFiles: { type: 'boolean', default: false },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
@ -88,6 +90,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();

View File

@ -52,6 +52,7 @@ export const paramDef = {
includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
},
required: [],
} as const;
@ -137,6 +138,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();

View File

@ -42,6 +42,7 @@ export const paramDef = {
properties: {
withFiles: { type: 'boolean', default: false },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
fileType: { type: 'array', items: {
type: 'string',
} },
@ -110,6 +111,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)');
}
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();

View File

@ -42,6 +42,7 @@ export const paramDef = {
includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
},
required: [],
} as const;
@ -126,6 +127,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
//#endregion
const timeline = await query.limit(ps.limit).getMany();

View File

@ -0,0 +1,88 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import ms from 'ms';
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository, NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteDeleteService } from '@/core/NoteDeleteService.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['notes'],
requireCredential: true,
requireRolePolicy: 'canEditNote',
kind: 'write:notes',
limit: {
duration: ms('1hour'),
max: 10,
minInterval: ms('1sec'),
},
errors: {
noSuchNote: {
message: 'No such note.',
code: 'NO_SUCH_NOTE',
id: 'a6584e14-6e01-4ad3-b566-851e7bf0d474',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
noteId: { type: 'string', format: 'misskey:id' },
text: {
type: 'string',
minLength: 1,
maxLength: MAX_NOTE_TEXT_LENGTH,
nullable: false,
},
cw: { type: 'string', nullable: true, maxLength: 100 },
},
required: ['noteId', 'text', 'cw'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private getterService: GetterService,
private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, me) => {
const note = await this.getterService.getNote(ps.noteId).catch(err => {
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
throw err;
});
if (note.userId !== me.id) {
throw new ApiError(meta.errors.noSuchNote);
}
await this.notesRepository.update({ id: note.id }, {
cw: ps.cw,
text: ps.text,
});
this.globalEventService.publishNoteStream(note.id, 'updated', {
cw: ps.cw,
text: ps.text,
});
});
}
}

View File

@ -49,6 +49,8 @@ export const paramDef = {
includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
withFiles: {
type: 'boolean',
default: false,
@ -130,6 +132,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}));
}
if (!ps.withReplies) {
query.andWhere('note.replyId IS NULL');
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}

View File

@ -41,8 +41,8 @@ export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
includeReplies: { type: 'boolean', default: true },
includeRenotes: { type: 'boolean', default: true },
withReplies: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
@ -115,11 +115,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
}
if (!ps.includeReplies) {
if (!ps.withReplies) {
query.andWhere('note.replyId IS NULL');
}
if (ps.includeRenotes === false) {
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {

View File

@ -19,6 +19,7 @@ class GlobalTimelineChannel extends Channel {
public static shouldShare = true;
public static requireCredential = false;
private withReplies: boolean;
private withRenotes: boolean;
constructor(
private metaService: MetaService,
@ -37,7 +38,8 @@ class GlobalTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.gtlAvailable) return;
this.withReplies = params.withReplies as boolean;
this.withReplies = params.withReplies ?? false;
this.withRenotes = params.withRenotes ?? true;
// Subscribe events
this.subscriber.on('notesStream', this.onNote);
@ -68,6 +70,8 @@ class GlobalTimelineChannel extends Channel {
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
}
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
// Ignore notes from instances the user has muted
if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return;

View File

@ -17,6 +17,7 @@ class HomeTimelineChannel extends Channel {
public static shouldShare = true;
public static requireCredential = true;
private withReplies: boolean;
private withRenotes: boolean;
constructor(
private noteEntityService: NoteEntityService,
@ -30,7 +31,8 @@ class HomeTimelineChannel extends Channel {
@bindThis
public async init(params: any) {
this.withReplies = params.withReplies as boolean;
this.withReplies = params.withReplies ?? false;
this.withRenotes = params.withRenotes ?? true;
this.subscriber.on('notesStream', this.onNote);
}
@ -77,6 +79,8 @@ class HomeTimelineChannel extends Channel {
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
}
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する

View File

@ -19,6 +19,7 @@ class HybridTimelineChannel extends Channel {
public static shouldShare = true;
public static requireCredential = true;
private withReplies: boolean;
private withRenotes: boolean;
constructor(
private metaService: MetaService,
@ -37,7 +38,8 @@ class HybridTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.ltlAvailable) return;
this.withReplies = params.withReplies as boolean;
this.withReplies = params.withReplies ?? false;
this.withRenotes = params.withRenotes ?? true;
// Subscribe events
this.subscriber.on('notesStream', this.onNote);
@ -89,6 +91,8 @@ class HybridTimelineChannel extends Channel {
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
}
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する

View File

@ -18,6 +18,7 @@ class LocalTimelineChannel extends Channel {
public static shouldShare = true;
public static requireCredential = false;
private withReplies: boolean;
private withRenotes: boolean;
constructor(
private metaService: MetaService,
@ -36,7 +37,8 @@ class LocalTimelineChannel extends Channel {
const policies = await this.roleService.getUserPolicies(this.user ? this.user.id : null);
if (!policies.ltlAvailable) return;
this.withReplies = params.withReplies as boolean;
this.withReplies = params.withReplies ?? false;
this.withRenotes = params.withRenotes ?? true;
// Subscribe events
this.subscriber.on('notesStream', this.onNote);
@ -68,6 +70,8 @@ class LocalTimelineChannel extends Channel {
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
}
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.userIdsWhoMeMuting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する

View File

@ -130,6 +130,10 @@ export interface NoteStreamTypes {
deleted: {
deletedAt: Date;
};
updated: {
cw: string | null;
text: string;
};
reacted: {
reaction: string;
emoji?: {

View File

@ -143,6 +143,7 @@ const props = withDefaults(defineProps<{
fixed?: boolean;
autofocus?: boolean;
freezeAfterPosted?: boolean;
updateMode?: boolean;
}>(), {
initialVisibleUsers: () => [],
autofocus: true,
@ -698,17 +699,18 @@ async function post(ev?: MouseEvent) {
}
let postData = {
text: text === '' ? undefined : text,
text: text === '' ? null : text,
fileIds: files.length > 0 ? files.map(f => f.id) : undefined,
replyId: props.reply ? props.reply.id : undefined,
renoteId: props.renote ? props.renote.id : quoteId ? quoteId : undefined,
channelId: props.channel ? props.channel.id : undefined,
poll: poll,
cw: useCw ? cw ?? '' : undefined,
cw: useCw ? cw ?? '' : null,
localOnly: localOnly,
visibility: visibility,
visibleUserIds: visibility === 'specified' ? visibleUsers.map(u => u.id) : undefined,
reactionAcceptance,
noteId: props.updateMode ? props.initialNote?.id : undefined,
};
if (withHashtags && hashtags && hashtags.trim() !== '') {
@ -731,7 +733,7 @@ async function post(ev?: MouseEvent) {
}
posting = true;
os.api('notes/create', postData, token).then(() => {
os.api(props.updateMode ? 'notes/update' : 'notes/create', postData, token).then(() => {
if (props.freezeAfterPosted) {
posted = true;
} else {
@ -819,8 +821,10 @@ function showActions(ev) {
action: () => {
action.handler({
text: text,
cw: cw,
}, (key, value) => {
if (key === 'text') { text = value; }
if (key === 'cw') { useCw = value !== null; cw = value; }
});
},
})), ev.currentTarget ?? ev.target);

View File

@ -30,6 +30,7 @@ const props = defineProps<{
instant?: boolean;
fixed?: boolean;
autofocus?: boolean;
updateMode?: boolean;
}>();
const emit = defineEmits<{

View File

@ -15,14 +15,19 @@ import * as sound from '@/scripts/sound.js';
import { $i } from '@/account.js';
import { defaultStore } from '@/store.js';
const props = defineProps<{
const props = withDefaults(defineProps<{
src: string;
list?: string;
antenna?: string;
channel?: string;
role?: string;
sound?: boolean;
}>();
withRenotes?: boolean;
withReplies?: boolean;
}>(), {
withRenotes: true,
withReplies: false,
});
const emit = defineEmits<{
(ev: 'note'): void;
@ -62,10 +67,12 @@ if (props.src === 'antenna') {
} else if (props.src === 'home') {
endpoint = 'notes/timeline';
query = {
withReplies: defaultStore.state.showTimelineReplies,
withRenotes: props.withRenotes,
withReplies: props.withReplies,
};
connection = stream.useChannel('homeTimeline', {
withReplies: defaultStore.state.showTimelineReplies,
withRenotes: props.withRenotes,
withReplies: props.withReplies,
});
connection.on('note', prepend);
@ -73,28 +80,34 @@ if (props.src === 'antenna') {
} else if (props.src === 'local') {
endpoint = 'notes/local-timeline';
query = {
withReplies: defaultStore.state.showTimelineReplies,
withRenotes: props.withRenotes,
withReplies: props.withReplies,
};
connection = stream.useChannel('localTimeline', {
withReplies: defaultStore.state.showTimelineReplies,
withRenotes: props.withRenotes,
withReplies: props.withReplies,
});
connection.on('note', prepend);
} else if (props.src === 'social') {
endpoint = 'notes/hybrid-timeline';
query = {
withReplies: defaultStore.state.showTimelineReplies,
withRenotes: props.withRenotes,
withReplies: props.withReplies,
};
connection = stream.useChannel('hybridTimeline', {
withReplies: defaultStore.state.showTimelineReplies,
withRenotes: props.withRenotes,
withReplies: props.withReplies,
});
connection.on('note', prepend);
} else if (props.src === 'global') {
endpoint = 'notes/global-timeline';
query = {
withReplies: defaultStore.state.showTimelineReplies,
withRenotes: props.withRenotes,
withReplies: props.withReplies,
};
connection = stream.useChannel('globalTimeline', {
withReplies: defaultStore.state.showTimelineReplies,
withRenotes: props.withRenotes,
withReplies: props.withReplies,
});
connection.on('note', prepend);
} else if (props.src === 'mentions') {
@ -116,9 +129,13 @@ if (props.src === 'antenna') {
} else if (props.src === 'list') {
endpoint = 'notes/user-list-timeline';
query = {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
listId: props.list,
};
connection = stream.useChannel('userList', {
withRenotes: props.withRenotes,
withReplies: props.withReplies,
listId: props.list,
});
connection.on('note', prepend);

View File

@ -45,7 +45,7 @@ import { onMounted, onUnmounted, ref, inject } from 'vue';
import tinycolor from 'tinycolor2';
import XTabs, { Tab } from './MkPageHeader.tabs.vue';
import { scrollToTop } from '@/scripts/scroll.js';
import { globalEvents } from '@/events';
import { globalEvents } from '@/events.js';
import { injectPageMetadata } from '@/scripts/page-metadata.js';
import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';

View File

@ -61,6 +61,7 @@ export const ROLE_POLICIES = [
'gtlAvailable',
'ltlAvailable',
'canPublicNote',
'canEditNote',
'canInvite',
'inviteLimit',
'inviteLimitCycle',

View File

@ -160,6 +160,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canEditNote, 'canEditNote'])">
<template #label>{{ i18n.ts._role._options.canEditNote }}</template>
<template #suffix>
<span v-if="role.policies.canEditNote.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canEditNote.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canEditNote)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canEditNote.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canEditNote.value" :disabled="role.policies.canEditNote.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canEditNote.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])">
<template #label>{{ i18n.ts._role._options.canInvite }}</template>
<template #suffix>

View File

@ -48,6 +48,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canEditNote, 'canEditNote'])">
<template #label>{{ i18n.ts._role._options.canEditNote }}</template>
<template #suffix>{{ policies.canEditNote ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canEditNote">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])">
<template #label>{{ i18n.ts._role._options.canInvite }}</template>
<template #suffix>{{ policies.canInvite ? i18n.ts.yes : i18n.ts.no }}</template>

View File

@ -29,7 +29,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_s">
<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
<MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch>
<MkSwitch v-model="showTimelineReplies">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></MkSwitch>
<MkFolder>
<template #label>{{ i18n.ts.pinnedList }}</template>
<!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ -->
@ -249,7 +248,6 @@ const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars'));
const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter('mediaListWithOneImageAppearance'));
const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition'));
const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis'));
const showTimelineReplies = computed(defaultStore.makeGetterSetter('showTimelineReplies'));
const keepScreenOn = computed(defaultStore.makeGetterSetter('keepScreenOn'));
watch(lang, () => {

View File

@ -15,9 +15,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.tl">
<MkTimeline
ref="tlComponent"
:key="src"
:key="src + withRenotes + withReplies"
:src="src.split(':')[0]"
:list="src.split(':')[1]"
:withRenotes="withRenotes"
:withReplies="withReplies"
:sound="true"
@queue="queueUpdated"
/>
@ -58,6 +60,8 @@ const rootEl = $shallowRef<HTMLElement>();
let queue = $ref(0);
let srcWhenNotSignin = $ref(isLocalTimelineAvailable ? 'local' : 'global');
const src = $computed({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin), set: (x) => saveSrc(x) });
const withRenotes = $ref(true);
const withReplies = $ref(false);
watch($$(src), () => queue = 0);
@ -129,7 +133,23 @@ function focus(): void {
tlComponent.focus();
}
const headerActions = $computed(() => []);
const headerActions = $computed(() => [{
icon: 'ti ti-dots',
text: i18n.ts.options,
handler: (ev) => {
os.popupMenu([{
type: 'switch',
text: i18n.ts.showRenotes,
icon: 'ti ti-repeat',
ref: $$(withRenotes),
}, {
type: 'switch',
text: i18n.ts.withReplies,
icon: 'ti ti-arrow-back-up',
ref: $$(withReplies),
}], ev.currentTarget ?? ev.target);
},
}]);
const headerTabs = $computed(() => [...(defaultStore.reactiveState.pinnedUserLists.value.map(l => ({
key: 'list:' + l.id,

View File

@ -36,8 +36,8 @@ const pagination = {
limit: 10,
params: computed(() => ({
userId: props.user.id,
includeRenotes: include.value === 'all',
includeReplies: include.value === 'all' || include.value === 'files',
withRenotes: include.value === 'all',
withReplies: include.value === 'all' || include.value === 'files',
withFiles: include.value === 'files',
})),
};

View File

@ -172,6 +172,10 @@ export function getNoteMenu(props: {
});
}
function edit(): void {
os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel, updateMode: true });
}
function toggleFavorite(favorite: boolean): void {
claimAchievement('noteFavorited1');
os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', {
@ -352,6 +356,11 @@ export function getNoteMenu(props: {
),
...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [
null,
appearNote.userId === $i.id && $i.policies.canEditNote ? {
icon: 'ti ti-edit',
text: i18n.ts.edit,
action: edit,
} : undefined,
appearNote.userId === $i.id ? {
icon: 'ti ti-edit',
text: i18n.ts.deleteAndEdit,

View File

@ -71,6 +71,12 @@ export function useNoteCapture(props: {
break;
}
case 'updated': {
note.value.cw = body.cw;
note.value.text = body.text;
break;
}
case 'deleted': {
props.isDeletedRef.value = true;
break;

View File

@ -109,10 +109,6 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'account',
default: [] as string[],
},
showTimelineReplies: {
where: 'account',
default: false,
},
menu: {
where: 'deviceAccount',

View File

@ -30,6 +30,8 @@ export type Column = {
roleId?: string;
includingTypes?: typeof notificationTypes[number][];
tl?: 'home' | 'local' | 'social' | 'global';
withRenotes?: boolean;
withReplies?: boolean;
};
export const deckStore = markRaw(new Storage('deck', {

View File

@ -20,12 +20,19 @@ SPDX-License-Identifier: AGPL-3.0-only
</p>
<p :class="$style.disabledDescription">{{ i18n.ts._disabledTimeline.description }}</p>
</div>
<MkTimeline v-else-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl"/>
<MkTimeline
v-else-if="column.tl"
ref="timeline"
:key="column.tl + withRenotes + withReplies"
:src="column.tl"
:withRenotes="withRenotes"
:withReplies="withReplies"
/>
</XColumn>
</template>
<script lang="ts" setup>
import { onMounted } from 'vue';
import { onMounted, watch } from 'vue';
import XColumn from './column.vue';
import { removeColumn, updateColumn, Column } from './deck-store.js';
import MkTimeline from '@/components/MkTimeline.vue';
@ -43,6 +50,20 @@ let disabled = $ref(false);
const isLocalTimelineAvailable = (($i == null && instance.policies.ltlAvailable) || ($i != null && $i.policies.ltlAvailable));
const isGlobalTimelineAvailable = (($i == null && instance.policies.gtlAvailable) || ($i != null && $i.policies.gtlAvailable));
const withRenotes = $ref(props.column.withRenotes ?? true);
const withReplies = $ref(props.column.withReplies ?? false);
watch($$(withRenotes), v => {
updateColumn(props.column.id, {
withRenotes: v,
});
});
watch($$(withReplies), v => {
updateColumn(props.column.id, {
withReplies: v,
});
});
onMounted(() => {
if (props.column.tl == null) {
@ -82,6 +103,14 @@ const menu = [{
icon: 'ti ti-pencil',
text: i18n.ts.timeline,
action: setType,
}, {
type: 'switch',
text: i18n.ts.showRenotes,
ref: $$(withRenotes),
}, {
type: 'switch',
text: i18n.ts.withReplies,
ref: $$(withReplies),
}];
</script>

View File

@ -133,6 +133,13 @@ export type NoteUpdatedEvent = {
body: {
deletedAt: string;
};
} | {
id: Note['id'];
type: 'updated';
body: {
cw: string | null;
text: string;
};
} | {
id: Note['id'];
type: 'pollVoted';