release: 2023.11.2

This commit is contained in:
Marie 2023-12-01 00:01:19 +01:00 committed by GitHub
commit 1022280465
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 706 additions and 407 deletions

View File

@ -112,6 +112,7 @@ redis:
# apiKey: ''
# ssl: true
# index: ''
# scope: global
# ┌───────────────┐
#───┘ ID generation └───────────────────────────────────────────
@ -144,15 +145,22 @@ id: 'aidx'
# Job concurrency per worker
# deliverJobConcurrency: 128
# inboxJobConcurrency: 16
# relashionshipJobConcurrency: 16
# What's relashionshipJob?:
# Follow, unfollow, block and unblock(ings) while following-imports, etc. or account migrations.
# Job rate limiter
# deliverJobPerSec: 128
# inboxJobPerSec: 16
# relashionshipJobPerSec: 64
# Job attempts
# deliverJobMaxAttempts: 12
# inboxJobMaxAttempts: 8
# Local address used for outgoing requests
#outgoingAddress: 127.0.0.1
# IP address family used for outgoing request (ipv4, ipv6 or dual)
#outgoingAddressFamily: ipv4
@ -175,8 +183,15 @@ proxyBypassHosts:
#mediaProxy: https://example.com/proxy
# Proxy remote files (default: true)
# Proxy remote files by this instance or mediaProxy to prevent remote files from running in remote domains.
proxyRemoteFiles: true
# Movie Thumbnail Generation URL
# There is no reference implementation.
# For example, Misskey will point to the following URL:
# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4
#videoThumbnailGenerator: https://example.com
# Sign to ActivityPub GET request (default: true)
signToActivityPubGet: true

View File

@ -8,7 +8,7 @@ jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: actions/first-interaction@v1.2.0
- uses: actions/first-interaction@v1.3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: |

View File

@ -55,6 +55,7 @@ addToAntenna: "Add to antenna"
sendMessage: "Send a message"
copyRSS: "Copy RSS"
copyUsername: "Copy username"
openRemoteProfile: "Open remote profile"
copyUserId: "Copy user ID"
copyNoteId: "Copy note ID"
copyFileId: "Copy file ID"
@ -110,7 +111,6 @@ renote: "Boost"
unrenote: "Remove boost"
renoted: "Boosted."
quoted: "Quoted."
rmquote: "Removed quote."
rmboost: "Unboosted."
cantRenote: "This post can't be boosted."
cantReRenote: "A boost can't be boosted."
@ -891,6 +891,7 @@ continueThread: "View thread continuation"
deleteAccountConfirm: "This will irreversibly delete your account. Proceed?"
incorrectPassword: "Incorrect password."
voteConfirm: "Confirm your vote for \"{choice}\"?"
voteConfirmMulti: "Confirm your vote for \"{choice}\"?\n You can choose more options after confirmation."
hide: "Hide"
useDrawerReactionPickerForMobile: "Display reaction picker as drawer on mobile"
welcomeBackWithName: "Welcome back, {name}"
@ -987,6 +988,7 @@ cannotLoad: "Unable to load"
numberOfProfileView: "Profile views"
like: "Like"
unlike: "Unlike"
defaultLike: "Default like emoji"
numberOfLikes: "Likes"
show: "Show"
neverShow: "Don't show again"
@ -1855,6 +1857,14 @@ _ago:
monthsAgo: "{n}mo ago"
yearsAgo: "{n}y ago"
invalid: "None"
_timeIn:
seconds: "in {n} seconds"
minutes: "in {n} minutes"
hours: "in {n} hours"
days: "in {n} days"
weeks: "in {n} weeks"
months: "in {n} months"
years: "in {n} years"
_time:
second: "Second(s)"
minute: "Minute(s)"
@ -1980,6 +1990,7 @@ _widgets:
_userList:
chooseList: "Select a list"
clicker: "Clicker"
search: "Search"
_cw:
hide: "Hide"
show: "Show content"
@ -2007,6 +2018,7 @@ _poll:
remainingHours: "{h} hour(s) {m} minute(s) remaining"
remainingMinutes: "{m} minute(s) {s} second(s) remaining"
remainingSeconds: "{s} second(s) remaining"
multiple: "Multiple choices"
_visibility:
public: "Public"
publicDescription: "Your note will be visible for all users"

6
locales/index.d.ts vendored
View File

@ -58,6 +58,7 @@ export interface Locale {
"sendMessage": string;
"copyRSS": string;
"copyUsername": string;
"openRemoteProfile": string;
"copyUserId": string;
"copyNoteId": string;
"copyFileId": string;
@ -114,7 +115,6 @@ export interface Locale {
"renoted": string;
"quoted": string;
"rmboost": string;
"rmquote": string;
"cantRenote": string;
"cantReRenote": string;
"quote": string;
@ -894,6 +894,7 @@ export interface Locale {
"deleteAccountConfirm": string;
"incorrectPassword": string;
"voteConfirm": string;
"voteConfirmMulti": string;
"hide": string;
"useDrawerReactionPickerForMobile": string;
"welcomeBackWithName": string;
@ -990,6 +991,7 @@ export interface Locale {
"numberOfProfileView": string;
"like": string;
"unlike": string;
"defaultLike": string;
"numberOfLikes": string;
"show": string;
"neverShow": string;
@ -2125,6 +2127,7 @@ export interface Locale {
"chooseList": string;
};
"clicker": string;
"search": string;
};
"_cw": {
"hide": string;
@ -2154,6 +2157,7 @@ export interface Locale {
"remainingHours": string;
"remainingMinutes": string;
"remainingSeconds": string;
"multiple": string;
};
"_visibility": {
"public": string;

View File

@ -54,6 +54,7 @@ addToAntenna: "Aggiungi all'antenna"
sendMessage: "Invia messaggio"
copyRSS: "Copia RSS"
copyUsername: "Copia nome utente"
openRemoteProfile: "Apri profilo remoto"
copyUserId: "Copia ID del profilo"
copyNoteId: "Copia ID della Nota"
copyFileId: "Copia ID del file"
@ -731,6 +732,8 @@ thisIsExperimentalFeature: "Questa è una funzionalità sperimentale. Potrebbe e
developer: "Sviluppatore"
makeExplorable: "Profilo visibile pubblicamente nella pagina \"Esplora\""
makeExplorableDescription: "Disabilitando questa opzione, il tuo profilo non verrà elencato nella pagina \"Esplora\"."
makeIndexable: "Non indicizzare le note pubbliche"
makeIndexableDescription: "Le tue note pubbliche non saranno cercabili"
showGapBetweenNotesInTimeline: "Mostrare un intervallo tra le note sulla timeline"
duplicate: "Duplica"
left: "Sinistra"
@ -964,6 +967,7 @@ cannotLoad: "Caricamento impossibile"
numberOfProfileView: "Visualizzazioni profilo"
like: "Mi piace!"
unlike: "Non mi piace"
defaultLike: "Emoji predefinita per \"mi piace\""
numberOfLikes: "Numero di Like"
show: "Visualizza"
neverShow: "Non mostrare più"
@ -1266,6 +1270,8 @@ _serverSettings:
shortName: "Abbreviazione"
shortNameDescription: "Un'abbreviazione o un nome comune che può essere visualizzato al posto del nome ufficiale lungo del server."
fanoutTimelineDescription: "Attivando questa funzionalità migliori notevolmente la capacità delle Timeline di collezionare Note, riducendo il carico sul database. Tuttavia, aumenterà l'impiego di memoria RAM per Redis. Disattiva se il tuo server ha poca RAM o la funzionalità è irregolare."
fanoutTimelineDbFallback: "Ripiega sul database"
fanoutTimelineDbFallbackDescription: "Attivando questa funzionalità, nel caso che il contenuto di una Timeline non sia presente nella cache, verrà consultato il database. Disattivandola, il carico sul database sarà ulteriormente ridotto, ma le Timeline saranno limitate"
_accountMigration:
moveFrom: "Migra un altro profilo dentro a questo"
moveFromSub: "Crea un alias verso un altro profilo remoto"
@ -1702,6 +1708,7 @@ _serverDisconnectedBehavior:
reload: "Ricarica automaticamente"
dialog: "Apri avviso in finestra"
quiet: "Visualizza avviso in modo discreto"
disabled: "Non visualizzare l'avviso"
_channel:
create: "Nuovo canale"
edit: "Gerisci canale"
@ -1817,6 +1824,14 @@ _ago:
monthsAgo: "{n} mesi fa"
yearsAgo: "{n} anni fa"
invalid: "Niente da visualizzare"
_timeIn:
seconds: "fra {n} secondi"
minutes: "fra {n} minuti"
hours: "fra {n} ore"
days: "fra {n} giorni"
weeks: "fra {n} settimane"
months: "fra {n} mesi"
years: "fra {n} anni"
_time:
second: "s"
minute: "min"

View File

@ -55,6 +55,7 @@ addToAntenna: "アンテナに追加"
sendMessage: "メッセージを送信"
copyRSS: "RSSをコピー"
copyUsername: "ユーザー名をコピー"
openRemoteProfile: "リモートプロファイルを開く"
copyUserId: "ユーザーIDをコピー"
copyNoteId: "ートIDをコピー"
copyFileId: "ファイルIDをコピー"
@ -111,7 +112,6 @@ unrenote: "リノート解除"
renoted: "ブースト。"
quoted: "引用。"
rmboost: "アンブースト。"
rmquote: "引用を削除しました。"
cantRenote: "この投稿はリノートできません。"
cantReRenote: "リノートをリノートすることはできません。"
quote: "引用"
@ -891,6 +891,7 @@ continueThread: "さらにスレッドを見る"
deleteAccountConfirm: "アカウントが削除されます。よろしいですか?"
incorrectPassword: "パスワードが間違っています。"
voteConfirm: "「{choice}」に投票しますか?"
voteConfirmMulti: "「{choice}」に投票しますか?\n 確認後、選択肢を増やすことができます。"
hide: "隠す"
useDrawerReactionPickerForMobile: "モバイルデバイスのときドロワーで表示"
welcomeBackWithName: "おかえりなさい、{name}さん"
@ -987,6 +988,7 @@ cannotLoad: "読み込めません"
numberOfProfileView: "プロフィール表示回数"
like: "いいね!"
unlike: "いいねを解除"
defaultLike: "絵文字のようなデフォルト"
numberOfLikes: "いいね数"
show: "表示"
neverShow: "今後表示しない"
@ -2029,6 +2031,7 @@ _widgets:
_userList:
chooseList: "リストを選択"
clicker: "クリッカー"
search: "検索"
_cw:
hide: "隠す"
@ -2058,6 +2061,7 @@ _poll:
remainingHours: "終了まであと{h}時間{m}分"
remainingMinutes: "終了まであと{m}分{s}秒"
remainingSeconds: "終了まであと{s}秒"
multiple: "複数の選択肢"
_visibility:
public: "パブリック"
@ -2403,7 +2407,7 @@ _externalResourceInstaller:
_themeInstallFailed:
title: "テーマのインストールに失敗しました"
description: "テーマのインストール中に問題が発生しました。もう一度お試しください。エラーの詳細はJavascriptコンソールをご覧ください。"
_animatedMFM:
play: "MFMアニメーションを再生"
stop: "MFMアニメーション停止"

View File

@ -1,6 +1,6 @@
{
"name": "sharkey",
"version": "2023.11.1",
"version": "2023.11.2",
"codename": "shonk",
"repository": {
"type": "git",

View File

@ -12,7 +12,7 @@ import { EmojiEntityService } from '@/core/entities/EmojiEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiEmoji } from '@/models/Emoji.js';
import type { EmojisRepository, MiRole, MiUser } from '@/models/_.js';
import type { DriveFilesRepository, EmojisRepository, MiRole, MiUser } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js';
@ -20,6 +20,7 @@ import { query } from '@/misc/prelude/url.js';
import type { Serialized } from '@/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import type { Config } from '@/config.js';
import { DriveService } from './DriveService.js';
const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/;
@ -38,11 +39,15 @@ export class CustomEmojiService implements OnApplicationShutdown {
@Inject(DI.emojisRepository)
private emojisRepository: EmojisRepository,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private utilityService: UtilityService,
private idService: IdService,
private emojiEntityService: EmojiEntityService,
private moderationLogService: ModerationLogService,
private globalEventService: GlobalEventService,
private driveService: DriveService,
) {
this.cache = new MemoryKVCache<MiEmoji | null>(1000 * 60 * 60 * 12);
@ -259,6 +264,12 @@ export class CustomEmojiService implements OnApplicationShutdown {
this.localEmojisCache.refresh();
const file = await this.driveFilesRepository.findOneBy({ url: emoji.originalUrl, userHost: emoji.host ? emoji.host : IsNull() });
if (file) {
await this.driveService.deleteFile(file, false, moderator ? moderator : undefined);
}
this.globalEventService.publishBroadcastStream('emojiDeleted', {
emojis: [await this.emojiEntityService.packDetailed(emoji)],
});
@ -280,6 +291,12 @@ export class CustomEmojiService implements OnApplicationShutdown {
for (const emoji of emojis) {
await this.emojisRepository.delete(emoji.id);
const file = await this.driveFilesRepository.findOneBy({ url: emoji.originalUrl, userHost: emoji.host ? emoji.host : IsNull() });
if (file) {
await this.driveService.deleteFile(file, false, moderator ? moderator : undefined);
}
if (moderator) {
this.moderationLogService.log(moderator, 'deleteCustomEmoji', {
emojiId: emoji.id,

View File

@ -14,18 +14,15 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js';
import type { NoteEditRepository, ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { NoteEditRepository, ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository, PollsRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js';
import { concat } from '@/misc/prelude/array.js';
import { IdService } from '@/core/IdService.js';
import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
import { MiPoll, type IPoll } from '@/models/Poll.js';
import { checkWordMute } from '@/misc/check-word-mute.js';
import type { MiChannel } from '@/models/Channel.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { MemorySingleCache } from '@/misc/cache.js';
import type { MiUserProfile } from '@/models/UserProfile.js';
import { RelayService } from '@/core/RelayService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { DI } from '@/di-symbols.js';
@ -35,7 +32,6 @@ import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { WebhookService } from '@/core/WebhookService.js';
import { HashtagService } from '@/core/HashtagService.js';
import { QueueService } from '@/core/QueueService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
@ -48,11 +44,7 @@ import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { RoleService } from '@/core/RoleService.js';
import { MetaService } from '@/core/MetaService.js';
import { SearchService } from '@/core/SearchService.js';
import { FeaturedService } from '@/core/FeaturedService.js';
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
import { AntennaService } from './AntennaService.js';
import NotesChart from './chart/charts/notes.js';
import PerUserNotesChart from './chart/charts/per-user-notes.js';
import { UtilityService } from '@/core/UtilityService.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -191,6 +183,9 @@ export class NoteEditService implements OnApplicationShutdown {
@Inject(DI.noteEditRepository)
private noteEditRepository: NoteEditRepository,
@Inject(DI.pollsRepository)
private pollsRepository: PollsRepository,
private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService,
private idService: IdService,
@ -201,18 +196,13 @@ export class NoteEditService implements OnApplicationShutdown {
private notificationService: NotificationService,
private relayService: RelayService,
private federatedInstanceService: FederatedInstanceService,
private hashtagService: HashtagService,
private antennaService: AntennaService,
private webhookService: WebhookService,
private featuredService: FeaturedService,
private remoteUserResolveService: RemoteUserResolveService,
private apDeliverManagerService: ApDeliverManagerService,
private apRendererService: ApRendererService,
private roleService: RoleService,
private metaService: MetaService,
private searchService: SearchService,
private notesChart: NotesChart,
private perUserNotesChart: PerUserNotesChart,
private activeUsersChart: ActiveUsersChart,
private instanceChart: InstanceChart,
private utilityService: UtilityService,
@ -385,6 +375,10 @@ export class NoteEditService implements OnApplicationShutdown {
update.hasPoll = !!data.poll;
}
const poll = await this.pollsRepository.findOneBy({ noteId: oldnote.id });
const oldPoll = poll ? { choices: poll.choices, multiple: poll.multiple, expiresAt: poll.expiresAt } : null;
if (Object.keys(update).length > 0) {
const exists = await this.noteEditRepository.findOneBy({ noteId: oldnote.id });
@ -456,7 +450,7 @@ export class NoteEditService implements OnApplicationShutdown {
}));
}
if (data.poll != null) {
if (data.poll != null && JSON.stringify(data.poll) !== JSON.stringify(oldPoll)) {
// Start transaction
await this.db.transaction(async transactionalEntityManager => {
await transactionalEntityManager.update(MiNote, oldnote.id, note);

View File

@ -278,14 +278,14 @@ export class QueueService {
}
@bindThis
public createImportMastoToDbJob(user: ThinUser, targets: string[]) {
const jobs = targets.map(rel => this.generateToDbJobData('importMastoToDb', { user, target: rel }));
public createImportMastoToDbJob(user: ThinUser, targets: string[], note: MiNote['id'] | null) {
const jobs = targets.map(rel => this.generateToDbJobData('importMastoToDb', { user, target: rel, note }));
return this.dbQueue.addBulk(jobs);
}
@bindThis
public createImportPleroToDbJob(user: ThinUser, targets: string[]) {
const jobs = targets.map(rel => this.generateToDbJobData('importPleroToDb', { user, target: rel }));
public createImportPleroToDbJob(user: ThinUser, targets: string[], note: MiNote['id'] | null) {
const jobs = targets.map(rel => this.generateToDbJobData('importPleroToDb', { user, target: rel, note }));
return this.dbQueue.addBulk(jobs);
}

View File

@ -171,6 +171,7 @@ export class ApRendererService {
mediaType: file.webpublicType ?? file.type,
url: this.driveFileEntityService.getPublicUrl(file),
name: file.comment,
summary: file.comment,
};
}

View File

@ -11,7 +11,7 @@ export interface IObject {
type: string | string[];
id?: string;
name?: string | null;
summary?: string;
summary?: string | null;
_misskey_summary?: string;
published?: string;
cc?: ApObject;

View File

@ -3,7 +3,7 @@ import * as vm from 'node:vm';
import { Inject, Injectable } from '@nestjs/common';
import { ZipReader } from 'slacc';
import { DI } from '@/di-symbols.js';
import type { UsersRepository, DriveFilesRepository, MiDriveFile, MiNote, NotesRepository, MiUser } from '@/models/_.js';
import type { UsersRepository, DriveFilesRepository, MiDriveFile, MiNote, NotesRepository, MiUser, DriveFoldersRepository, MiDriveFolder } from '@/models/_.js';
import type Logger from '@/logger.js';
import { DownloadService } from '@/core/DownloadService.js';
import { bindThis } from '@/decorators.js';
@ -14,9 +14,10 @@ import { DriveService } from '@/core/DriveService.js';
import { MfmService } from '@/core/MfmService.js';
import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js';
import { extractApHashtagObjects } from '@/core/activitypub/models/tag.js';
import { IdService } from '@/core/IdService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
import type { DbNoteImportToDbJobData, DbNoteImportJobData, DbKeyNoteImportToDbJobData } from '../types.js';
import type { DbNoteImportToDbJobData, DbNoteImportJobData, DbNoteWithParentImportToDbJobData } from '../types.js';
@Injectable()
export class ImportNotesProcessorService {
@ -29,6 +30,9 @@ export class ImportNotesProcessorService {
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@Inject(DI.driveFoldersRepository)
private driveFoldersRepository: DriveFoldersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@ -38,20 +42,21 @@ export class ImportNotesProcessorService {
private apNoteService: ApNoteService,
private driveService: DriveService,
private downloadService: DownloadService,
private idService: IdService,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('import-notes');
}
@bindThis
private async uploadFiles(dir: string, user: MiUser) {
private async uploadFiles(dir: string, user: MiUser, folder?: MiDriveFolder['id']) {
const fileList = fs.readdirSync(dir);
for await (const file of fileList) {
const name = `${dir}/${file}`;
if (fs.statSync(name).isDirectory()) {
await this.uploadFiles(name, user);
await this.uploadFiles(name, user, folder);
} else {
const exists = await this.driveFilesRepository.findOneBy({ name: file, userId: user.id });
const exists = await this.driveFilesRepository.findOneBy({ name: file, userId: user.id, folderId: folder });
if (file.endsWith('.srt')) return;
@ -60,6 +65,7 @@ export class ImportNotesProcessorService {
user: user,
path: name,
name: file,
folderId: folder,
});
}
}
@ -68,7 +74,7 @@ export class ImportNotesProcessorService {
// Function was taken from Firefish and modified for our needs
@bindThis
private async recreateChain(idField: string, replyField: string, arr: any[]): Promise<any[]> {
private async recreateChain(idFieldPath: string[], replyFieldPath: string[], arr: any[], includeOrphans: boolean): Promise<any[]> {
type NotesMap = {
[id: string]: any;
};
@ -77,28 +83,42 @@ export class ImportNotesProcessorService {
const notesWaitingForParent: NotesMap = {};
for await (const note of arr) {
noteById[note[idField]] = note;
const noteId = idFieldPath.reduce(
(obj, step) => obj[step],
note,
);
noteById[noteId] = note;
note.childNotes = [];
const children = notesWaitingForParent[note[idField]];
const children = notesWaitingForParent[noteId];
if (children) {
note.childNotes.push(...children);
delete notesWaitingForParent[noteId];
}
if (note[replyField] == null) {
const noteReplyId = replyFieldPath.reduce(
(obj, step) => obj[step],
note,
);
if (noteReplyId == null) {
notesTree.push(note);
continue;
}
const parent = noteById[note[replyField]];
const parent = noteById[noteReplyId];
if (parent) {
parent.childNotes.push(note);
} else {
notesWaitingForParent[note[replyField]] ||= [];
notesWaitingForParent[note[replyField]].push(note);
notesWaitingForParent[noteReplyId] ||= [];
notesWaitingForParent[noteReplyId].push(note);
}
}
if (includeOrphans) {
notesTree.push(...Object.values(notesWaitingForParent).flat(1));
}
return notesTree;
}
@ -126,6 +146,12 @@ export class ImportNotesProcessorService {
return;
}
let folder = await this.driveFoldersRepository.findOneBy({ name: 'Imports', userId: job.data.user.id });
if (folder == null) {
await this.driveFoldersRepository.insert({ id: this.idService.gen(), name: 'Imports', userId: job.data.user.id });
folder = await this.driveFoldersRepository.findOneBy({ name: 'Imports', userId: job.data.user.id });
}
const type = job.data.type;
if (type === 'Twitter' || file.name.startsWith('twitter') && file.name.endsWith('.zip')) {
@ -164,7 +190,7 @@ export class ImportNotesProcessorService {
const tweets = Object.keys(fakeWindow.window.YTD.tweets.part0).reduce((m, key, i, obj) => {
return m.concat(fakeWindow.window.YTD.tweets.part0[key].tweet);
}, []);
const processedTweets = await this.recreateChain("id_str", "in_reply_to_status_id_str", tweets);
const processedTweets = await this.recreateChain(['id_str'], ['in_reply_to_status_id_str'], tweets, false);
this.queueService.createImportTweetsToDbJob(job.data.user, processedTweets, null);
} finally {
cleanup();
@ -192,7 +218,12 @@ export class ImportNotesProcessorService {
ZipReader.withDestinationPath(outputPath).viaBuffer(await fs.promises.readFile(destPath));
const postsJson = fs.readFileSync(outputPath + '/your_activity_across_facebook/posts/your_posts__check_ins__photos_and_videos_1.json', 'utf-8');
const posts = JSON.parse(postsJson);
await this.uploadFiles(outputPath + '/your_activity_across_facebook/posts/media', user);
const facebookFolder = await this.driveFoldersRepository.findOneBy({ name: 'Facebook', userId: job.data.user.id, parentId: folder?.id });
if (facebookFolder == null && folder) {
await this.driveFoldersRepository.insert({ id: this.idService.gen(), name: 'Facebook', userId: job.data.user.id, parentId: folder.id });
const createdFolder = await this.driveFoldersRepository.findOneBy({ name: 'Facebook', userId: job.data.user.id, parentId: folder.id });
if (createdFolder) await this.uploadFiles(outputPath + '/your_activity_across_facebook/posts/media', user, createdFolder.id);
}
this.queueService.createImportFBToDbJob(job.data.user, posts);
} finally {
cleanup();
@ -223,7 +254,12 @@ export class ImportNotesProcessorService {
if (isInstagram) {
const postsJson = fs.readFileSync(outputPath + '/content/posts_1.json', 'utf-8');
const posts = JSON.parse(postsJson);
await this.uploadFiles(outputPath + '/media/posts', user);
const igFolder = await this.driveFoldersRepository.findOneBy({ name: 'Instagram', userId: job.data.user.id, parentId: folder?.id });
if (igFolder == null && folder) {
await this.driveFoldersRepository.insert({ id: this.idService.gen(), name: 'Instagram', userId: job.data.user.id, parentId: folder.id });
const createdFolder = await this.driveFoldersRepository.findOneBy({ name: 'Instagram', userId: job.data.user.id, parentId: folder.id });
if (createdFolder) await this.uploadFiles(outputPath + '/media/posts', user, createdFolder.id);
}
this.queueService.createImportIGToDbJob(job.data.user, posts);
} else if (isOutbox) {
const actorJson = fs.readFileSync(outputPath + '/actor.json', 'utf-8');
@ -232,12 +268,21 @@ export class ImportNotesProcessorService {
if (isPleroma) {
const outboxJson = fs.readFileSync(outputPath + '/outbox.json', 'utf-8');
const outbox = JSON.parse(outboxJson);
this.queueService.createImportPleroToDbJob(job.data.user, outbox.orderedItems.filter((x: any) => x.type === 'Create' && x.object.type === 'Note'));
const processedToots = await this.recreateChain(['object', 'id'], ['object', 'inReplyTo'], outbox.orderedItems.filter((x: any) => x.type === 'Create' && x.object.type === 'Note'), true);
this.queueService.createImportPleroToDbJob(job.data.user, processedToots, null);
} else {
const outboxJson = fs.readFileSync(outputPath + '/outbox.json', 'utf-8');
const outbox = JSON.parse(outboxJson);
if (fs.existsSync(outputPath + '/media_attachments/files')) await this.uploadFiles(outputPath + '/media_attachments/files', user);
this.queueService.createImportMastoToDbJob(job.data.user, outbox.orderedItems.filter((x: any) => x.type === 'Create' && x.object.type === 'Note'));
let mastoFolder = await this.driveFoldersRepository.findOneBy({ name: 'Mastodon', userId: job.data.user.id, parentId: folder?.id });
if (mastoFolder == null && folder) {
await this.driveFoldersRepository.insert({ id: this.idService.gen(), name: 'Mastodon', userId: job.data.user.id, parentId: folder.id });
mastoFolder = await this.driveFoldersRepository.findOneBy({ name: 'Mastodon', userId: job.data.user.id, parentId: folder.id });
}
if (fs.existsSync(outputPath + '/media_attachments/files') && mastoFolder) {
await this.uploadFiles(outputPath + '/media_attachments/files', user, mastoFolder.id);
}
const processedToots = await this.recreateChain(['object', 'id'], ['object', 'inReplyTo'], outbox.orderedItems.filter((x: any) => x.type === 'Create' && x.object.type === 'Note'), true);
this.queueService.createImportMastoToDbJob(job.data.user, processedToots, null);
}
}
} finally {
@ -260,7 +305,7 @@ export class ImportNotesProcessorService {
const notesJson = fs.readFileSync(path, 'utf-8');
const notes = JSON.parse(notesJson);
const processedNotes = await this.recreateChain("id", "replyId", notes);
const processedNotes = await this.recreateChain(['id'], ['replyId'], notes, false);
this.queueService.createImportKeyNotesToDbJob(job.data.user, processedNotes, null);
cleanup();
}
@ -269,7 +314,7 @@ export class ImportNotesProcessorService {
}
@bindThis
public async processKeyNotesToDb(job: Bull.Job<DbKeyNoteImportToDbJobData>): Promise<void> {
public async processKeyNotesToDb(job: Bull.Job<DbNoteWithParentImportToDbJobData>): Promise<void> {
const note = job.data.target;
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
@ -280,16 +325,25 @@ export class ImportNotesProcessorService {
const parentNote = job.data.note ? await this.notesRepository.findOneBy({ id: job.data.note }) : null;
const folder = await this.driveFoldersRepository.findOneBy({ name: 'Imports', userId: job.data.user.id });
if (folder == null) return;
const files: MiDriveFile[] = [];
const date = new Date(note.createdAt);
if (note.files && this.isIterable(note.files)) {
let keyFolder = await this.driveFoldersRepository.findOneBy({ name: 'Misskey', userId: job.data.user.id, parentId: folder.id });
if (keyFolder == null) {
await this.driveFoldersRepository.insert({ id: this.idService.gen(), name: 'Misskey', userId: job.data.user.id, parentId: folder.id });
keyFolder = await this.driveFoldersRepository.findOneBy({ name: 'Misskey', userId: job.data.user.id, parentId: folder.id });
}
for await (const file of note.files) {
const [filePath, cleanup] = await createTemp();
const slashdex = file.url.lastIndexOf('/');
const name = file.url.substring(slashdex + 1);
const exists = await this.driveFilesRepository.findOneBy({ name: name, userId: user.id });
const exists = await this.driveFilesRepository.findOneBy({ name: name, userId: user.id }) ?? await this.driveFilesRepository.findOneBy({ name: name, userId: user.id, folderId: keyFolder?.id });
if (!exists) {
try {
@ -301,6 +355,7 @@ export class ImportNotesProcessorService {
user: user,
path: filePath,
name: name,
folderId: keyFolder?.id,
});
files.push(driveFile);
} else {
@ -316,28 +371,33 @@ export class ImportNotesProcessorService {
}
@bindThis
public async processMastoToDb(job: Bull.Job<DbNoteImportToDbJobData>): Promise<void> {
public async processMastoToDb(job: Bull.Job<DbNoteWithParentImportToDbJobData>): Promise<void> {
const toot = job.data.target;
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
return;
}
if (toot.directMessage) return;
const date = new Date(toot.object.published);
let text = undefined;
const files: MiDriveFile[] = [];
let reply: MiNote | null = null;
if (toot.object.inReplyTo != null) {
try {
reply = await this.apNoteService.resolveNote(toot.object.inReplyTo);
} catch (error) {
reply = null;
const parentNote = job.data.note ? await this.notesRepository.findOneBy({ id: job.data.note }) : null;
if (parentNote) {
reply = parentNote;
} else {
try {
reply = await this.apNoteService.resolveNote(toot.object.inReplyTo);
} catch (error) {
reply = null;
}
}
}
if (toot.directMessage) return;
const hashtags = extractApHashtagObjects(toot.object.tag).map((x) => x.name).filter((x): x is string => x != null);
try {
@ -357,32 +417,41 @@ export class ImportNotesProcessorService {
}
}
await this.noteCreateService.import(user, { createdAt: date, text: text, files: files, apMentions: new Array(0), cw: toot.object.sensitive ? toot.object.summary : null, reply: reply });
const createdNote = await this.noteCreateService.import(user, { createdAt: date, text: text, files: files, apMentions: new Array(0), cw: toot.object.sensitive ? toot.object.summary : null, reply: reply });
if (toot.childNotes) this.queueService.createImportMastoToDbJob(user, toot.childNotes, createdNote.id);
}
@bindThis
public async processPleroToDb(job: Bull.Job<DbNoteImportToDbJobData>): Promise<void> {
public async processPleroToDb(job: Bull.Job<DbNoteWithParentImportToDbJobData>): Promise<void> {
const post = job.data.target;
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
return;
}
if (post.directMessage) return;
const date = new Date(post.object.published);
let text = undefined;
const files: MiDriveFile[] = [];
let reply: MiNote | null = null;
const folder = await this.driveFoldersRepository.findOneBy({ name: 'Imports', userId: job.data.user.id });
if (folder == null) return;
if (post.object.inReplyTo != null) {
try {
reply = await this.apNoteService.resolveNote(post.object.inReplyTo);
} catch (error) {
reply = null;
const parentNote = job.data.note ? await this.notesRepository.findOneBy({ id: job.data.note }) : null;
if (parentNote) {
reply = parentNote;
} else {
try {
reply = await this.apNoteService.resolveNote(post.object.inReplyTo);
} catch (error) {
reply = null;
}
}
}
if (post.directMessage) return;
const hashtags = extractApHashtagObjects(post.object.tag).map((x) => x.name).filter((x): x is string => x != null);
try {
@ -392,12 +461,18 @@ export class ImportNotesProcessorService {
}
if (post.object.attachment && this.isIterable(post.object.attachment)) {
let pleroFolder = await this.driveFoldersRepository.findOneBy({ name: 'Pleroma', userId: job.data.user.id, parentId: folder.id });
if (pleroFolder == null) {
await this.driveFoldersRepository.insert({ id: this.idService.gen(), name: 'Pleroma', userId: job.data.user.id, parentId: folder.id });
pleroFolder = await this.driveFoldersRepository.findOneBy({ name: 'Pleroma', userId: job.data.user.id, parentId: folder.id });
}
for await (const file of post.object.attachment) {
const slashdex = file.url.lastIndexOf('/');
const name = file.url.substring(slashdex + 1);
const [filePath, cleanup] = await createTemp();
const exists = await this.driveFilesRepository.findOneBy({ name: name, userId: user.id });
const exists = await this.driveFilesRepository.findOneBy({ name: name, userId: user.id }) ?? await this.driveFilesRepository.findOneBy({ name: name, userId: user.id, folderId: pleroFolder?.id });
if (!exists) {
try {
@ -409,6 +484,7 @@ export class ImportNotesProcessorService {
user: user,
path: filePath,
name: name,
folderId: pleroFolder?.id,
});
files.push(driveFile);
} else {
@ -419,7 +495,8 @@ export class ImportNotesProcessorService {
}
}
await this.noteCreateService.import(user, { createdAt: date, text: text, files: files, apMentions: new Array(0), cw: post.object.sensitive ? post.object.summary : null, reply: reply });
const createdNote = await this.noteCreateService.import(user, { createdAt: date, text: text, files: files, apMentions: new Array(0), cw: post.object.sensitive ? post.object.summary : null, reply: reply });
if (post.childNotes) this.queueService.createImportPleroToDbJob(user, post.childNotes, createdNote.id);
}
@bindThis
@ -468,13 +545,16 @@ export class ImportNotesProcessorService {
}
@bindThis
public async processTwitterDb(job: Bull.Job<DbKeyNoteImportToDbJobData>): Promise<void> {
public async processTwitterDb(job: Bull.Job<DbNoteWithParentImportToDbJobData>): Promise<void> {
const tweet = job.data.target;
const user = await this.usersRepository.findOneBy({ id: job.data.user.id });
if (user == null) {
return;
}
const folder = await this.driveFoldersRepository.findOneBy({ name: 'Imports', userId: job.data.user.id });
if (folder == null) return;
const parentNote = job.data.note ? await this.notesRepository.findOneBy({ id: job.data.note }) : null;
async function replaceTwitterUrls(full_text: string, urls: any) {
@ -500,13 +580,19 @@ export class ImportNotesProcessorService {
const files: MiDriveFile[] = [];
if (tweet.extended_entities && this.isIterable(tweet.extended_entities.media)) {
let twitFolder = await this.driveFoldersRepository.findOneBy({ name: 'Twitter', userId: job.data.user.id, parentId: folder.id });
if (twitFolder == null) {
await this.driveFoldersRepository.insert({ id: this.idService.gen(), name: 'Twitter', userId: job.data.user.id, parentId: folder.id });
twitFolder = await this.driveFoldersRepository.findOneBy({ name: 'Twitter', userId: job.data.user.id, parentId: folder.id });
}
for await (const file of tweet.extended_entities.media) {
if (file.video_info) {
const [filePath, cleanup] = await createTemp();
const slashdex = file.video_info.variants[0].url.lastIndexOf('/');
const name = file.video_info.variants[0].url.substring(slashdex + 1);
const exists = await this.driveFilesRepository.findOneBy({ name: name, userId: user.id });
const exists = await this.driveFilesRepository.findOneBy({ name: name, userId: user.id }) ?? await this.driveFilesRepository.findOneBy({ name: name, userId: user.id, folderId: twitFolder?.id });
const videos = file.video_info.variants.filter((x: any) => x.content_type === 'video/mp4');
@ -520,6 +606,7 @@ export class ImportNotesProcessorService {
user: user,
path: filePath,
name: name,
folderId: twitFolder?.id,
});
files.push(driveFile);
} else {
@ -545,6 +632,7 @@ export class ImportNotesProcessorService {
user: user,
path: filePath,
name: name,
folderId: twitFolder?.id,
});
files.push(driveFile);
} else {

View File

@ -50,12 +50,12 @@ export type DbJobMap = {
exportUserLists: DbJobDataWithUser;
importAntennas: DBAntennaImportJobData;
importNotes: DbNoteImportJobData;
importTweetsToDb: DbKeyNoteImportToDbJobData;
importTweetsToDb: DbNoteWithParentImportToDbJobData;
importIGToDb: DbNoteImportToDbJobData;
importFBToDb: DbNoteImportToDbJobData;
importMastoToDb: DbNoteImportToDbJobData;
importPleroToDb: DbNoteImportToDbJobData;
importKeyNotesToDb: DbKeyNoteImportToDbJobData;
importMastoToDb: DbNoteWithParentImportToDbJobData;
importPleroToDb: DbNoteWithParentImportToDbJobData;
importKeyNotesToDb: DbNoteWithParentImportToDbJobData;
importFollowing: DbUserImportJobData;
importFollowingToDb: DbUserImportToDbJobData;
importMuting: DbUserImportJobData;
@ -113,7 +113,7 @@ export type DbNoteImportToDbJobData = {
target: any;
};
export type DbKeyNoteImportToDbJobData = {
export type DbNoteWithParentImportToDbJobData = {
user: ThinUser;
target: any;
note: MiNote['id'] | null;

View File

@ -13,7 +13,7 @@ export const meta = {
prohibitMoved: true,
limit: {
duration: ms('1hour'),
max: 5,
max: 2,
},
errors: {

View File

@ -14,7 +14,7 @@ import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ["notes"],
tags: ['notes'],
requireCredential: true,
@ -23,99 +23,99 @@ export const meta = {
max: 300,
},
kind: "write:notes",
kind: 'write:notes',
res: {
type: "object",
type: 'object',
optional: false,
nullable: false,
properties: {
createdNote: {
type: "object",
type: 'object',
optional: false,
nullable: false,
ref: "Note",
ref: 'Note',
},
},
},
errors: {
noSuchRenoteTarget: {
message: "No such renote target.",
code: "NO_SUCH_RENOTE_TARGET",
id: "b5c90186-4ab0-49c8-9bba-a1f76c282ba4",
message: 'No such renote target.',
code: 'NO_SUCH_RENOTE_TARGET',
id: 'b5c90186-4ab0-49c8-9bba-a1f76c282ba4',
},
cannotReRenote: {
message: "You can not Renote a pure Renote.",
code: "CANNOT_RENOTE_TO_A_PURE_RENOTE",
id: "fd4cc33e-2a37-48dd-99cc-9b806eb2031a",
message: 'You can not Renote a pure Renote.',
code: 'CANNOT_RENOTE_TO_A_PURE_RENOTE',
id: 'fd4cc33e-2a37-48dd-99cc-9b806eb2031a',
},
noSuchReplyTarget: {
message: "No such reply target.",
code: "NO_SUCH_REPLY_TARGET",
id: "749ee0f6-d3da-459a-bf02-282e2da4292c",
message: 'No such reply target.',
code: 'NO_SUCH_REPLY_TARGET',
id: '749ee0f6-d3da-459a-bf02-282e2da4292c',
},
cannotReplyToPureRenote: {
message: "You can not reply to a pure Renote.",
code: "CANNOT_REPLY_TO_A_PURE_RENOTE",
id: "3ac74a84-8fd5-4bb0-870f-01804f82ce15",
message: 'You can not reply to a pure Renote.',
code: 'CANNOT_REPLY_TO_A_PURE_RENOTE',
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce15',
},
cannotCreateAlreadyExpiredPoll: {
message: "Poll is already expired.",
code: "CANNOT_CREATE_ALREADY_EXPIRED_POLL",
id: "04da457d-b083-4055-9082-955525eda5a5",
message: 'Poll is already expired.',
code: 'CANNOT_CREATE_ALREADY_EXPIRED_POLL',
id: '04da457d-b083-4055-9082-955525eda5a5',
},
noSuchChannel: {
message: "No such channel.",
code: "NO_SUCH_CHANNEL",
id: "b1653923-5453-4edc-b786-7c4f39bb0bbb",
message: 'No such channel.',
code: 'NO_SUCH_CHANNEL',
id: 'b1653923-5453-4edc-b786-7c4f39bb0bbb',
},
youHaveBeenBlocked: {
message: "You have been blocked by this user.",
code: "YOU_HAVE_BEEN_BLOCKED",
id: "b390d7e1-8a5e-46ed-b625-06271cafd3d3",
message: 'You have been blocked by this user.',
code: 'YOU_HAVE_BEEN_BLOCKED',
id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3',
},
accountLocked: {
message: "You migrated. Your account is now locked.",
code: "ACCOUNT_LOCKED",
id: "d390d7e1-8a5e-46ed-b625-06271cafd3d3",
message: 'You migrated. Your account is now locked.',
code: 'ACCOUNT_LOCKED',
id: 'd390d7e1-8a5e-46ed-b625-06271cafd3d3',
},
needsEditId: {
message: "You need to specify `editId`.",
code: "NEEDS_EDIT_ID",
id: "d697edc8-8c73-4de8-bded-35fd198b79e5",
message: 'You need to specify `editId`.',
code: 'NEEDS_EDIT_ID',
id: 'd697edc8-8c73-4de8-bded-35fd198b79e5',
},
noSuchNote: {
message: "No such note.",
code: "NO_SUCH_NOTE",
id: "eef6c173-3010-4a23-8674-7c4fcaeba719",
message: 'No such note.',
code: 'NO_SUCH_NOTE',
id: 'eef6c173-3010-4a23-8674-7c4fcaeba719',
},
youAreNotTheAuthor: {
message: "You are not the author of this note.",
code: "YOU_ARE_NOT_THE_AUTHOR",
id: "c6e61685-411d-43d0-b90a-a448d2539001",
message: 'You are not the author of this note.',
code: 'YOU_ARE_NOT_THE_AUTHOR',
id: 'c6e61685-411d-43d0-b90a-a448d2539001',
},
cannotPrivateRenote: {
message: "You can not perform a private renote.",
code: "CANNOT_PRIVATE_RENOTE",
id: "19a50f1c-84fa-4e33-81d3-17834ccc0ad8",
message: 'You can not perform a private renote.',
code: 'CANNOT_PRIVATE_RENOTE',
id: '19a50f1c-84fa-4e33-81d3-17834ccc0ad8',
},
notLocalUser: {
message: "You are not a local user.",
code: "NOT_LOCAL_USER",
id: "b907f407-2aa0-4283-800b-a2c56290b822",
message: 'You are not a local user.',
code: 'NOT_LOCAL_USER',
id: 'b907f407-2aa0-4283-800b-a2c56290b822',
},
cannotRenoteOutsideOfChannel: {
@ -127,60 +127,63 @@ export const meta = {
} as const;
export const paramDef = {
type: "object",
type: 'object',
properties: {
editId: { type: "string", format: "misskey:id" },
visibility: { type: "string", enum: ['public', 'home', 'followers', 'specified'], default: "public" },
editId: { type: 'string', format: 'misskey:id' },
visibility: { type: 'string', enum: ['public', 'home', 'followers', 'specified'], default: 'public' },
visibleUserIds: {
type: "array",
type: 'array',
uniqueItems: true,
items: {
type: "string",
format: "misskey:id",
type: 'string',
format: 'misskey:id',
},
},
text: { type: "string", maxLength: MAX_NOTE_TEXT_LENGTH, nullable: true },
cw: { type: "string", nullable: true, minLength: 1, maxLength: 250 },
localOnly: { type: "boolean", default: false },
noExtractMentions: { type: "boolean", default: false },
noExtractHashtags: { type: "boolean", default: false },
noExtractEmojis: { type: "boolean", default: false },
cw: { type: 'string', nullable: true, minLength: 1, maxLength: 250 },
localOnly: { type: 'boolean', default: false },
reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null },
noExtractMentions: { type: 'boolean', default: false },
noExtractHashtags: { type: 'boolean', default: false },
noExtractEmojis: { type: 'boolean', default: false },
replyId: { type: 'string', format: 'misskey:id', nullable: true },
renoteId: { type: 'string', format: 'misskey:id', nullable: true },
channelId: { type: 'string', format: 'misskey:id', nullable: true },
text: {
type: 'string',
minLength: 1,
maxLength: MAX_NOTE_TEXT_LENGTH,
nullable: true,
},
fileIds: {
type: "array",
type: 'array',
uniqueItems: true,
minItems: 1,
maxItems: 16,
items: { type: "string", format: "misskey:id" },
items: { type: 'string', format: 'misskey:id' },
},
mediaIds: {
deprecated: true,
description:
"Use `fileIds` instead. If both are specified, this property is discarded.",
type: "array",
type: 'array',
uniqueItems: true,
minItems: 1,
maxItems: 16,
items: { type: "string", format: "misskey:id" },
items: { type: 'string', format: 'misskey:id' },
},
replyId: { type: "string", format: "misskey:id", nullable: true },
renoteId: { type: "string", format: "misskey:id", nullable: true },
channelId: { type: "string", format: "misskey:id", nullable: true },
poll: {
type: "object",
type: 'object',
nullable: true,
properties: {
choices: {
type: "array",
type: 'array',
uniqueItems: true,
minItems: 2,
maxItems: 10,
items: { type: "string", minLength: 1, maxLength: 50 },
items: { type: 'string', minLength: 1, maxLength: 50 },
},
multiple: { type: "boolean", default: false },
expiresAt: { type: "integer", nullable: true },
expiredAfter: { type: "integer", nullable: true, minimum: 1 },
multiple: { type: 'boolean' },
expiresAt: { type: 'integer', nullable: true },
expiredAfter: { type: 'integer', nullable: true, minimum: 1 },
},
required: ["choices"],
required: ['choices'],
},
},
anyOf: [
@ -188,32 +191,32 @@ export const paramDef = {
// (re)note with text, files and poll are optional
properties: {
text: {
type: "string",
type: 'string',
minLength: 1,
maxLength: MAX_NOTE_TEXT_LENGTH,
nullable: false,
},
},
required: ["text"],
required: ['text'],
},
{
// (re)note with files, text and poll are optional
required: ["fileIds"],
required: ['fileIds'],
},
{
// (re)note with files, text and poll are optional
required: ["mediaIds"],
required: ['mediaIds'],
},
{
// (re)note with poll, text and files are optional
properties: {
poll: { type: "object", nullable: false },
poll: { type: 'object', nullable: false },
},
required: ["poll"],
required: ['poll'],
},
{
// pure renote
required: ["renoteId"],
required: ['renoteId'],
},
],
} as const;

View File

@ -139,7 +139,9 @@ export class ClientServerService {
'type': 'image/png',
'purpose': 'maskable',
}, {
'src': '/static-assets/splash.png',
// 空文字列の場合右辺を使いたいため
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
'src': instance.app512IconUrl || '/static-assets/icons/512.png',
'sizes': '300x300',
'type': 'image/png',
'purpose': 'any',

View File

@ -39,9 +39,7 @@ export async function mainBoot() {
let reloadDialogShowing = false;
stream.on('_disconnected_', async () => {
if (defaultStore.state.serverDisconnectedBehavior === 'reload') {
location.reload();
} else if (defaultStore.state.serverDisconnectedBehavior === 'dialog') {
if (defaultStore.state.serverDisconnectedBehavior === 'dialog') {
if (reloadDialogShowing) return;
reloadDialogShowing = true;
const { canceled } = await confirm({

View File

@ -57,6 +57,48 @@ const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages
}
}
.root + .root {
position: relative;
margin-inline: -20px 0;
box-shadow: -4px 0 0 var(--panel), -15px 0 15px var(--panel);
overflow: clip;
isolation: isolate;
&::before {
content: "";
position: absolute;
inset: 0;
background: var(--panel);
z-index: -1;
}
&::after {
content: "";
position: absolute;
inset: 0;
background: var(--panel);
z-index: -1;
background: inherit;
}
span {
display: inline-block;
white-space: nowrap;
max-width: 3em;
mask: linear-gradient(to right, #000 20%, rgba(0, 0, 0, 0.4));
}
+ .root {
margin-inline: -10px 0;
padding-inline-end: 0;
box-shadow: -4px 0 0 var(--panel);
span {
display: none;
}
}
}
.icon {
width: 1.5em;
height: 1.5em;

View File

@ -127,9 +127,8 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="quoteButton"
:class="$style.footerButton"
class="_button"
:style="quoted ? 'color: var(--accent) !important;' : ''"
v-on:click.stop
@mousedown="quoted ? undoQuote(appearNote) : quote()"
@mousedown="quote()"
>
<i class="ph-quotes ph-bold ph-lg"></i>
</button>
@ -226,7 +225,10 @@ const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', nul
let note = $ref(deepClone(props.note));
function noteclick(id: string) {
router.push(`/notes/${id}`);
const selection = document.getSelection();
if (selection?.toString().length === 0) {
router.push(`/notes/${id}`);
}
}
// plugin
@ -278,14 +280,13 @@ const isLong = shouldCollapsed(appearNote, urls ?? []);
const collapsed = ref(appearNote.cw == null && isLong);
const isDeleted = ref(false);
const renoted = ref(false);
const quoted = ref(false);
const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
const translation = ref<any>(null);
const translating = ref(false);
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i.id));
let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null)));
const defaultLike = computed(() => defaultStore.state.like !== '❤️' ? defaultStore.state.like : null);
const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
const keymap = {
'r': () => reply(true),
@ -364,15 +365,6 @@ if (!props.mock) {
}).then((res) => {
renoted.value = res.length > 0;
});
os.api("notes/renotes", {
noteId: appearNote.id,
userId: $i.id,
limit: 1,
quote: true,
}).then((res) => {
quoted.value = res.length > 0;
});
}
}
@ -467,7 +459,6 @@ function quote() {
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
quoted.value = res.length > 0;
os.toast(i18n.ts.quoted);
});
});
@ -490,7 +481,6 @@ function quote() {
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
quoted.value = res.length > 0;
os.toast(i18n.ts.quoted);
});
});
@ -603,26 +593,6 @@ function undoRenote(note) : void {
}
}
function undoQuote(note) : void {
if (props.mock) {
return;
}
os.api("notes/unrenote", {
noteId: note.id,
quote: true
});
os.toast(i18n.ts.rmquote);
quoted.value = false;
const el = quoteButton.value as HTMLElement | null | undefined;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
}
function onContextmenu(ev: MouseEvent): void {
if (props.mock) {
return;

View File

@ -137,8 +137,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="quoteButton"
class="_button"
:class="$style.noteFooterButton"
:style="quoted ? 'color: var(--accent) !important;' : ''"
@mousedown="quoted ? undoQuote() : quote()"
@mousedown="quote()"
>
<i class="ph-quotes ph-bold ph-lg"></i>
</button>
@ -310,7 +309,6 @@ const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false);
const isDeleted = ref(false);
const renoted = ref(false);
const quoted = ref(false);
const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
const translation = ref(null);
const translating = ref(false);
@ -323,7 +321,7 @@ const conversation = ref<Misskey.entities.Note[]>([]);
const replies = ref<Misskey.entities.Note[]>([]);
const quotes = ref<Misskey.entities.Note[]>([]);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id);
const defaultLike = computed(() => defaultStore.state.like !== '❤️' ? defaultStore.state.like : null);
const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
watch(() => props.expandAllCws, (expandAllCws) => {
if (expandAllCws !== showContent.value) showContent.value = expandAllCws;
@ -337,15 +335,6 @@ if ($i) {
}).then((res) => {
renoted.value = res.length > 0;
});
os.api("notes/renotes", {
noteId: appearNote.id,
userId: $i.id,
limit: 1,
quote: true,
}).then((res) => {
quoted.value = res.length > 0;
});
}
const keymap = {
@ -511,7 +500,6 @@ function quote() {
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
quoted.value = res.length > 0;
os.toast(i18n.ts.quoted);
});
});
@ -534,7 +522,6 @@ function quote() {
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
quoted.value = res.length > 0;
os.toast(i18n.ts.quoted);
});
});
@ -625,23 +612,6 @@ function undoRenote() : void {
}
}
function undoQuote() : void {
os.api("notes/unrenote", {
noteId: appearNote.id,
quote: true
});
os.toast(i18n.ts.rmquote);
quoted.value = false;
const el = quoteButton.value as HTMLElement | null | undefined;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
}
function onContextmenu(ev: MouseEvent): void {
const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true;

View File

@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div>
<p v-if="note.cw != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<MkCwButton v-model="showContent" :note="note"/>
<MkCwButton v-model="showContent" :note="note" v-on:click.stop/>
</p>
<div v-show="note.cw == null || showContent">
<MkSubNoteContent :hideFiles="hideFiles" :class="$style.text" :note="note"/>

View File

@ -41,8 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only
ref="quoteButton"
class="_button"
:class="$style.noteFooterButton"
:style="quoted ? 'color: var(--accent) !important;' : ''"
@mousedown="quoted ? undoQuote() : quote()"
@mousedown="quote()"
>
<i class="ph-quotes ph-bold ph-lg"></i>
</button>
@ -125,7 +124,6 @@ const translation = ref<any>(null);
const translating = ref(false);
const isDeleted = ref(false);
const renoted = ref(false);
const quoted = ref(false);
const reactButton = shallowRef<HTMLElement>();
const renoteButton = shallowRef<HTMLElement>();
const quoteButton = shallowRef<HTMLElement>();
@ -133,7 +131,7 @@ const menuButton = shallowRef<HTMLElement>();
const likeButton = shallowRef<HTMLElement>();
let appearNote = $computed(() => isRenote ? props.note.renote as Misskey.entities.Note : props.note);
const defaultLike = computed(() => defaultStore.state.like !== '❤️' ? defaultStore.state.like : null);
const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null);
const isRenote = (
props.note.renote != null &&
@ -156,15 +154,6 @@ if ($i) {
}).then((res) => {
renoted.value = res.length > 0;
});
os.api("notes/renotes", {
noteId: appearNote.id,
userId: $i.id,
limit: 1,
quote: true,
}).then((res) => {
quoted.value = res.length > 0;
});
}
function focus() {
@ -255,23 +244,6 @@ function undoRenote() : void {
}
}
function undoQuote() : void {
os.api("notes/unrenote", {
noteId: appearNote.id,
quote: true
});
os.toast(i18n.ts.rmquote);
quoted.value = false;
const el = quoteButton.value as HTMLElement | null | undefined;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
}
let showContent = $ref(false);
watch(() => props.expandAllCws, (expandAllCws) => {
@ -342,7 +314,6 @@ function quote() {
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
quoted.value = res.length > 0;
os.toast(i18n.ts.quoted);
});
});
@ -365,7 +336,6 @@ function quote() {
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
quoted.value = res.length > 0;
os.toast(i18n.ts.quoted);
});
});

View File

@ -17,6 +17,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</ul>
<p v-if="!readOnly" :class="$style.info">
<span>{{ i18n.t('_poll.totalVotes', { n: total }) }}</span>
<span v-if="note.poll.multiple"> · </span>
<span v-if="note.poll.multiple">{{ i18n.ts._poll.multiple }}</span>
<span> · </span>
<a v-if="!closed && !isVoted" style="color: inherit;" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a>
<span v-if="isVoted">{{ i18n.ts._poll.voted }}</span>
@ -78,12 +80,19 @@ const vote = async (id) => {
pleaseLogin();
if (props.readOnly || closed.value || isVoted.value) return;
const { canceled } = await os.confirm({
type: 'question',
text: i18n.t('voteConfirm', { choice: props.note.poll.choices[id].text }),
});
if (canceled) return;
if (!props.note.poll.multiple) {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.t('voteConfirm', { choice: props.note.poll.choices[id].text }),
});
if (canceled) return;
} else {
const { canceled } = await os.confirm({
type: 'question',
text: i18n.t('voteConfirmMulti', { choice: props.note.poll.choices[id].text }),
});
if (canceled) return;
}
await os.api('notes/polls/vote', {
noteId: props.note.id,

View File

@ -931,8 +931,8 @@ onMounted(() => {
poll = {
choices: init.poll.choices.map(x => x.text),
multiple: init.poll.multiple,
expiresAt: init.poll.expiresAt,
expiredAfter: init.poll.expiredAfter,
expiresAt: init.poll.expiresAt ? new Date(init.poll.expiresAt).getTime().toString() : null,
expiredAfter: init.poll.expiredAfter ? new Date(init.poll.expiredAfter).getTime().toString() : null,
};
}
visibility = init.visibility;

View File

@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkLoading v-if="translating" mini/>
<div v-else>
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
<Mfm :text="translation.text" :author="note.user" :nyaize="'account'" :emojiUrls="note.emojis"/>
<Mfm :text="translation.text" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
</div>
</div>
<MkA v-if="note.renoteId" :class="$style.rp" :to="`/notes/${note.renoteId}`" v-on:click.stop>RN: ...</MkA>
@ -63,7 +63,10 @@ const props = defineProps<{
const router = useRouter();
function noteclick(id: string) {
router.push(`/notes/${id}`);
const selection = document.getSelection();
if (selection?.toString().length === 0) {
router.push(`/notes/${id}`);
}
}
const parsed = $computed(() => props.note.text ? mfm.parse(props.note.text) : null);

View File

@ -28,9 +28,20 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.username"><MkAcct :user="user"/></div>
</div>
<div :class="$style.description">
<Mfm v-if="user.description" :class="$style.mfm" :text="user.description" :author="user"/>
<Mfm v-if="user.description" :nyaize="false" :class="$style.mfm" :text="user.description" :author="user"/>
<div v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</div>
</div>
<div v-if="user.fields.length > 0" :class="$style.fields">
<dl v-for="(field, i) in user.fields" :key="i" :class="$style.field">
<dt :class="$style.fieldname">
<Mfm :text="field.name" :nyaize="false" :plain="true" :colored="false"/>
</dt>
<dd :class="$style.fieldvalue">
<Mfm :text="field.value" :nyaize="false" :author="user" :colored="false"/>
<i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ph-seal-check ph-bold ph-lg" :class="$style.verifiedLink"></i>
</dd>
</dl>
</div>
<div :class="$style.status">
<div :class="$style.statusItem">
<div :class="$style.statusItemLabel">{{ i18n.ts.notes }}</div>
@ -221,6 +232,48 @@ onMounted(() => {
border-bottom: solid 1px var(--divider);
}
.fields {
font-size: 0.8em;
padding: 16px;
border-top: solid 1px var(--divider);
border-bottom: solid 1px var(--divider);
}
.field {
display: flex;
padding: 0;
margin: 0;
&:not(:last-child) {
margin-bottom: 8px;
}
:deep(span) {
white-space: nowrap !important;
}
}
.fieldvalue {
width: 70%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
word-wrap: nowrap;
margin: 0;
}
.fieldname {
width: 100px;
max-height: 45px;
overflow: hidden;
white-space: nowrap;
display: inline;
text-overflow: ellipsis;
font-weight: bold;
text-align: center;
padding-inline-end: 10px;
}
.mfm {
display: -webkit-box;
-webkit-line-clamp: 5;

View File

@ -65,6 +65,13 @@ const props = defineProps<{
edit: boolean;
}>();
// This will not be available for now as I don't think this is needed
// const notesSearchAvailable = (($i == null && instance.policies.canSearchNotes) || ($i != null && $i.policies.canSearchNotes));
/* if (!notesSearchAvailable) {
const wid = widgetDefs.findIndex(widget => widget === 'search');
widgetDefs.splice(wid, 1);
} */
const emit = defineEmits<{
(ev: 'updateWidgets', widgets: Widget[]): void;
(ev: 'addWidget', widget: Widget): void;

View File

@ -0,0 +1,30 @@
<template>
<MkWindow ref="window" :initialWidth="600" :initialHeight="450" :canResize="true" @closed="emit('closed')">
<template #header>
<i class="ph-magnifying-glass ph-bold ph-lg" style="margin-right: 0.5em;"></i>
<b>Result</b>
</template>
<MkNotes :key="props.noteKey" :pagination="props.notePagination"/>
</MkWindow>
</template>
<script lang="ts" setup>
import { } from 'vue';
import type { Paging } from '@/components/MkPagination.vue';
import MkNotes from '@/components/MkNotes.vue';
import MkWindow from '@/components/MkWindow.vue';
const props = defineProps<{
noteKey: string | number | symbol | undefined;
notePagination: Paging;
}>();
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
</script>
<style lang="scss" module>
</style>

View File

@ -82,7 +82,13 @@ export default function(props: MfmProps) {
res.push(t);
}
res.shift();
return res;
// Don't wrap whitespaces in a span
if (text === ' ') {
return res;
}
return h('span', res);
} else {
return [text.replace(/\n/g, ' ')];
}

View File

@ -216,6 +216,7 @@ onUnmounted(() => {
&.active {
opacity: 1;
color: var(--accent);
}
&.animate {

View File

@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
<FromSlot>
<template #label>Default like emoji</template>
<template #label>{{ i18n.ts.defaultLike }}</template>
<MkCustomEmoji v-if="defaultLike.startsWith(':')" style="max-height: 3em; font-size: 1.1em;" :useOriginalSize="false" :class="$style.reaction" :name="defaultLike" :normal="true" :noStyle="true"/>
<MkEmoji v-else :emoji="defaultLike" style="max-height: 3em; font-size: 1.1em;" :normal="true" :noStyle="true"/>
<MkButton rounded :small="true" @click="chooseNewLike"><i class="ph-smiley ph-bold ph-lg"></i> Change</MkButton>

View File

@ -166,7 +166,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkSelect v-model="serverDisconnectedBehavior">
<template #label>{{ i18n.ts.whenServerDisconnected }}</template>
<option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option>
<option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option>
<option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option>
<option value="disabled">{{ i18n.ts._serverDisconnectedBehavior.disabled }}</option>

View File

@ -24,9 +24,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</FromSlot>
<FromSlot>
<template #label>Default like emoji</template>
<MkCustomEmoji v-if="like.startsWith(':')" style="max-height: 3em; font-size: 1.1em;" :useOriginalSize="false" :class="$style.reaction" :name="like" :normal="true" :noStyle="true"/>
<MkEmoji v-else :emoji="like" style="max-height: 3em; font-size: 1.1em;" :normal="true" :noStyle="true"/>
<template #label>{{ i18n.ts.defaultLike }}</template>
<MkCustomEmoji v-if="like && like.startsWith(':')" style="max-height: 3em; font-size: 1.1em;" :useOriginalSize="false" :class="$style.reaction" :name="like" :normal="true" :noStyle="true"/>
<MkEmoji v-else-if="like && !like.startsWith(':')" :emoji="like" style="max-height: 3em; font-size: 1.1em;" :normal="true" :noStyle="true"/>
<span v-else-if="!like">{{ i18n.ts.notSet }}</span>
<div class="_buttons" style="padding-top: 8px;">
<MkButton rounded :small="true" inline @click="chooseNewLike"><i class="ph-smiley ph-bold ph-lg"></i> Change</MkButton>
<MkButton rounded :small="true" inline @click="resetLike"><i class="ph-arrow-clockwise ph-bold ph-lg"></i> Reset</MkButton>
@ -82,6 +83,7 @@ import { defaultStore } from '@/store.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { deepClone } from '@/scripts/clone.js';
import { unisonReload } from '@/scripts/unison-reload.js';
let reactions = $ref(deepClone(defaultStore.state.reactions));
const like = $computed(defaultStore.makeGetterSetter('like'));
@ -91,6 +93,16 @@ const reactionPickerWidth = $computed(defaultStore.makeGetterSetter('reactionPic
const reactionPickerHeight = $computed(defaultStore.makeGetterSetter('reactionPickerHeight'));
const reactionPickerUseDrawerForMobile = $computed(defaultStore.makeGetterSetter('reactionPickerUseDrawerForMobile'));
async function reloadAsk() {
const { canceled } = await os.confirm({
type: 'info',
text: i18n.ts.reloadToApplySetting,
});
if (canceled) return;
unisonReload();
}
function save() {
defaultStore.set('reactions', reactions);
}
@ -134,13 +146,15 @@ function chooseEmoji(ev: MouseEvent) {
function chooseNewLike(ev: MouseEvent) {
os.pickEmoji(ev.currentTarget ?? ev.target, {
showPinned: false,
}).then(emoji => {
}).then(async emoji => {
defaultStore.set('like', emoji as string);
await reloadAsk();
});
}
function resetLike() {
defaultStore.set('like', '❤️');
async function resetLike() {
defaultStore.set('like', null);
await reloadAsk();
}
watch($$(reactions), () => {

View File

@ -176,7 +176,13 @@ export function getUserMenu(user: Misskey.entities.UserDetailed, router: Router
const canonical = user.host === null ? `@${user.username}` : `@${user.username}@${toUnicode(user.host)}`;
copyToClipboard(`${url}/${canonical}`);
},
}, {
}, ...(user.host ? [{
icon: 'ph-share ph-bold ph-lg',
text: i18n.ts.openRemoteProfile,
action: () => {
open(`${user.uri}`, '_blank');
},
}] : []), {
icon: 'ph-envelope ph-bold ph-lg',
text: i18n.ts.sendMessage,
action: () => {

View File

@ -112,7 +112,7 @@ export const defaultStore = markRaw(new Storage('base', {
},
like: {
where: 'account',
default: '❤️',
default: null as string | null,
},
mutedAds: {
where: 'account',
@ -188,7 +188,7 @@ export const defaultStore = markRaw(new Storage('base', {
},
serverDisconnectedBehavior: {
where: 'device',
default: 'quiet' as 'quiet' | 'reload' | 'dialog' | 'disabled',
default: 'disabled' as 'quiet' | 'dialog' | 'disabled',
},
nsfw: {
where: 'device',

View File

@ -109,7 +109,8 @@ watch(defaultStore.reactiveState.menuDisplay, () => {
$nav-icon-only-width: 78px; // TODO:
$avatar-size: 32px;
$avatar-margin: 8px;
position: sticky;
top: 16px;
padding: 0 16px;
box-sizing: border-box;
width: 260px;

View File

@ -253,9 +253,13 @@ onMounted(() => {
}
> .widgets {
//--panelBorder: none;
position: sticky;
top: 0;
width: 300px;
padding-bottom: calc(var(--margin) + env(safe-area-inset-bottom, 0px));
height: 100%;
padding-top: 16px;
box-sizing: border-box;
overflow: auto;
@media (max-width: $widgets-hide-threshold) {
display: none;

View File

@ -324,7 +324,7 @@ $widgets-hide-threshold: 1090px;
min-width: 0;
overflow: auto;
overflow-y: scroll;
overscroll-behavior: contain;
overscroll-behavior: unset;
background: var(--bg);
}

View File

@ -0,0 +1,155 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkContainer :showHeader="widgetProps.showHeader" class="skw-search">
<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @keydown="onInputKeydown">
<template #suffix>
<button style="border: none; background: none; margin-right: 0.5em; z-index: 2; pointer-events: auto; position: relative; margin-top: 0 auto;" @click="options"><i class="ph-funnel ph-bold ph-lg"></i></button>
<button style="border: none; background: none; z-index: 2; pointer-events: auto; position: relative; margin: 0 auto;" @click="search"><i class="ph-magnifying-glass ph-bold ph-lg"></i></button>
</template>
</MkInput>
</MkContainer>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, onMounted } from 'vue';
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import MkInput from '@/components/MkInput.vue';
import MkContainer from '@/components/MkContainer.vue';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { useRouter } from '@/router.js';
import { GetFormResultType } from '@/scripts/form.js';
const name = 'search';
const widgetPropsDef = {
showHeader: {
type: 'boolean' as const,
default: false,
},
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
const props = defineProps<WidgetComponentProps<WidgetProps>>();
const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
props,
emit,
);
function onInputKeydown(evt: KeyboardEvent) {
if (evt.key === 'Enter') {
evt.preventDefault();
evt.stopPropagation();
search();
}
}
const router = useRouter();
let key = $ref(0);
let searchQuery = $ref('');
let notePagination = $ref();
let searchOrigin = $ref('combined');
let user = $ref(null);
let isLocalOnly = $ref(false);
let order = $ref(true);
let filetype = $ref(null);
function options(ev) {
os.popupMenu([{
type: 'parent',
text: 'With File',
icon: 'ph-file ph-bold ph-lg',
children: [
{
type: 'button',
icon: 'ph-image ph-bold ph-lg',
text: 'With Images',
action: () => {
filetype = 'image';
},
},
{
type: 'button',
icon: 'ph-music-notes-simple ph-bold ph-lg',
text: 'With Audios',
action: () => {
filetype = 'audio';
},
},
{
type: 'button',
icon: 'ph-video ph-bold ph-lg',
text: 'With Videos',
action: () => {
filetype = 'video';
},
}],
}], ev.currentTarget ?? ev.target);
}
function selectUser() {
os.selectUser().then(_user => {
user = _user;
});
}
async function search() {
const query = searchQuery.toString().trim();
if (query == null || query === '') return;
if (query.startsWith('https://')) {
const promise = os.api('ap/show', {
uri: query,
});
os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject);
const res = await promise;
if (res.type === 'User') {
router.push(`/@${res.object.username}@${res.object.host}`);
} else if (res.type === 'Note') {
router.push(`/notes/${res.object.id}`);
}
return;
}
notePagination = {
endpoint: 'notes/search',
limit: 10,
params: {
query: searchQuery,
userId: user ? user.id : null,
order: order ? 'desc' : 'asc',
filetype: filetype,
},
};
if (isLocalOnly) notePagination.params.host = '.';
key++;
os.popup(defineAsyncComponent(() => import('@/components/SkSearchResultWindow.vue')), {
noteKey: key,
notePagination: notePagination,
}, {
}, 'closed');
}
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>

View File

@ -33,6 +33,7 @@ export default function(app: App) {
app.component('WidgetAichan', defineAsyncComponent(() => import('./WidgetAichan.vue')));
app.component('WidgetUserList', defineAsyncComponent(() => import('./WidgetUserList.vue')));
app.component('WidgetClicker', defineAsyncComponent(() => import('./WidgetClicker.vue')));
app.component('WidgetSearch', defineAsyncComponent(() => import('./WidgetSearch.vue')));
}
export const widgets = [
@ -63,4 +64,5 @@ export const widgets = [
'aichan',
'userList',
'clicker',
'search',
];

View File

@ -63,7 +63,7 @@
"@types/parse-link-header": "^2.0.3",
"@types/uuid": "^9.0.7",
"@types/ws": "^8.5.10",
"axios": "1.5.0",
"axios": "1.6.0",
"dayjs": "^1.11.10",
"form-data": "^4.0.0",
"https-proxy-agent": "^7.0.2",

View File

@ -217,6 +217,7 @@ export type Note = {
clippedCount?: number;
poll?: {
expiresAt: DateString | null;
expiredAfter: DateString | null;
multiple: boolean;
choices: {
isVoted: boolean;

View File

@ -1034,8 +1034,8 @@ importers:
specifier: ^8.5.10
version: 8.5.10
axios:
specifier: 1.5.0
version: 1.5.0
specifier: 1.6.0
version: 1.6.0
dayjs:
specifier: ^1.11.10
version: 1.11.10
@ -2285,15 +2285,6 @@ packages:
'@babel/helper-plugin-utils': 7.22.5
dev: true
/@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.22.11):
resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==}
peerDependencies:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.22.11
'@babel/helper-plugin-utils': 7.22.5
dev: true
/@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.23.3):
resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==}
peerDependencies:
@ -7294,7 +7285,7 @@ packages:
ts-dedent: 2.2.0
type-fest: 2.19.0
vue: 3.3.8(typescript@5.2.2)
vue-component-type-helpers: 1.8.22
vue-component-type-helpers: 1.8.24
transitivePeerDependencies:
- encoding
- supports-color
@ -7940,10 +7931,6 @@ packages:
'@types/unist': 2.0.6
dev: true
/@types/http-cache-semantics@4.0.1:
resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==}
dev: false
/@types/http-cache-semantics@4.0.4:
resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==}
@ -9198,7 +9185,7 @@ packages:
requiresBuild: true
dependencies:
delegates: 1.0.0
readable-stream: 3.6.0
readable-stream: 3.6.2
dev: false
/arg@5.0.2:
@ -9470,8 +9457,8 @@ packages:
- debug
dev: false
/axios@1.5.0:
resolution: {integrity: sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ==}
/axios@1.6.0:
resolution: {integrity: sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==}
dependencies:
follow-redirects: 1.15.3(debug@4.3.4)
form-data: 4.0.0
@ -9501,24 +9488,6 @@ packages:
'@babel/core': 7.22.11
dev: true
/babel-jest@29.7.0(@babel/core@7.22.11):
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
peerDependencies:
'@babel/core': ^7.8.0
dependencies:
'@babel/core': 7.22.11
'@jest/transform': 29.7.0
'@types/babel__core': 7.20.0
babel-plugin-istanbul: 6.1.1
babel-preset-jest: 29.6.3(@babel/core@7.22.11)
chalk: 4.1.2
graceful-fs: 4.2.11
slash: 3.0.0
transitivePeerDependencies:
- supports-color
dev: true
/babel-jest@29.7.0(@babel/core@7.23.3):
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -9596,26 +9565,6 @@ packages:
- supports-color
dev: true
/babel-preset-current-node-syntax@1.0.1(@babel/core@7.22.11):
resolution: {integrity: sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==}
peerDependencies:
'@babel/core': ^7.0.0
dependencies:
'@babel/core': 7.22.11
'@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.22.11)
'@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.22.11)
'@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.22.11)
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.22.11)
'@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.22.11)
'@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.22.11)
'@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.22.11)
'@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.22.11)
'@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.22.11)
'@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.22.11)
'@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.22.11)
'@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.22.11)
dev: true
/babel-preset-current-node-syntax@1.0.1(@babel/core@7.23.3):
resolution: {integrity: sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==}
peerDependencies:
@ -9636,17 +9585,6 @@ packages:
'@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.23.3)
dev: true
/babel-preset-jest@29.6.3(@babel/core@7.22.11):
resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
peerDependencies:
'@babel/core': ^7.0.0
dependencies:
'@babel/core': 7.22.11
babel-plugin-jest-hoist: 29.6.3
babel-preset-current-node-syntax: 1.0.1(@babel/core@7.22.11)
dev: true
/babel-preset-jest@29.6.3(@babel/core@7.23.3):
resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -9725,7 +9663,7 @@ packages:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.0
readable-stream: 3.6.2
/blob-util@2.0.2:
resolution: {integrity: sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==}
@ -9992,10 +9930,10 @@ packages:
resolution: {integrity: sha512-IDVO5MJ4LItE6HKFQTqT2ocAQsisOoCTUDu1ddCmnhyiwFQjXNPp4081Xj23N4tO+AFEFNzGuNEf/c8Gwwt15A==}
engines: {node: '>=14.16'}
dependencies:
'@types/http-cache-semantics': 4.0.1
'@types/http-cache-semantics': 4.0.4
get-stream: 6.0.1
http-cache-semantics: 4.1.1
keyv: 4.5.2
keyv: 4.5.4
mimic-response: 4.0.0
normalize-url: 8.0.0
responselike: 3.0.0
@ -10504,7 +10442,7 @@ packages:
crc-32: 1.2.2
crc32-stream: 5.0.0
normalize-path: 3.0.0
readable-stream: 3.6.0
readable-stream: 3.6.2
dev: false
/compressible@2.0.18:
@ -10635,28 +10573,9 @@ packages:
engines: {node: '>= 12.0.0'}
dependencies:
crc-32: 1.2.2
readable-stream: 3.6.0
readable-stream: 3.6.2
dev: false
/create-jest@29.7.0:
resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
hasBin: true
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
jest-config: 29.7.0(@types/node@20.9.4)
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
dev: true
/create-jest@29.7.0(@types/node@20.9.1):
resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -11091,6 +11010,7 @@ packages:
/deepmerge@4.2.2:
resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==}
engines: {node: '>=0.10.0'}
dev: false
/deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
@ -11314,7 +11234,7 @@ packages:
dependencies:
end-of-stream: 1.4.4
inherits: 2.0.4
readable-stream: 2.3.7
readable-stream: 2.3.8
stream-shift: 1.0.1
dev: true
@ -13951,10 +13871,10 @@ packages:
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
create-jest: 29.7.0
create-jest: 29.7.0(@types/node@20.9.1)
exit: 0.1.2
import-local: 3.1.0
jest-config: 29.7.0(@types/node@20.9.4)
jest-config: 29.7.0(@types/node@20.9.1)
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
@ -14005,14 +13925,14 @@ packages:
ts-node:
optional: true
dependencies:
'@babel/core': 7.22.11
'@babel/core': 7.23.3
'@jest/test-sequencer': 29.7.0
'@jest/types': 29.6.3
'@types/node': 20.9.1
babel-jest: 29.7.0(@babel/core@7.22.11)
babel-jest: 29.7.0(@babel/core@7.23.3)
chalk: 4.1.2
ci-info: 3.7.1
deepmerge: 4.2.2
ci-info: 3.9.0
deepmerge: 4.3.1
glob: 7.2.3
graceful-fs: 4.2.11
jest-circus: 29.7.0
@ -14741,12 +14661,6 @@ packages:
safe-buffer: 5.2.1
dev: false
/keyv@4.5.2:
resolution: {integrity: sha512-5MHbFaKn8cNSmVW7BYnijeAVlE4cYA/SVkifVgrh7yotnfhKmjuXpDKjrABLnT0SfHWV21P8ow07OGfRrNDg8g==}
dependencies:
json-buffer: 3.0.1
dev: false
/keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
dependencies:
@ -14800,7 +14714,7 @@ packages:
resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==}
engines: {node: '>= 0.6.3'}
dependencies:
readable-stream: 2.3.7
readable-stream: 2.3.8
dev: false
/leven@3.1.0:
@ -17504,17 +17418,6 @@ packages:
type-fest: 0.6.0
dev: true
/readable-stream@2.3.7:
resolution: {integrity: sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==}
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
/readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
dependencies:
@ -17525,7 +17428,6 @@ packages:
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
dev: true
/readable-stream@3.6.0:
resolution: {integrity: sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==}
@ -17534,6 +17436,7 @@ packages:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
dev: false
/readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
@ -18995,7 +18898,7 @@ packages:
/through2@2.0.5:
resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==}
dependencies:
readable-stream: 2.3.7
readable-stream: 2.3.8
xtend: 4.0.2
dev: true
@ -19956,8 +19859,8 @@ packages:
/vscode-textmate@8.0.0:
resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==}
/vue-component-type-helpers@1.8.22:
resolution: {integrity: sha512-LK3wJHs3vJxHG292C8cnsRusgyC5SEZDCzDCD01mdE/AoREFMl2tzLRuzwyuEsOIz13tqgBcnvysN3Lxsa14Fw==}
/vue-component-type-helpers@1.8.24:
resolution: {integrity: sha512-lqWs/7fdRXoSBAlbouHBX+LNuaY6gI9xWW34m/ZIz9zVPYHEyw0b2/zaCBwlKx0NtKTeF/6pOpvrxVkh7nhIYg==}
dev: true
/vue-component-type-helpers@1.8.4: