diff --git a/.config/ci.yml b/.config/ci.yml index c48fca49b..5fc2c787a 100644 --- a/.config/ci.yml +++ b/.config/ci.yml @@ -106,7 +106,7 @@ redis: # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── -# You can set scope to local (default value) or global +# You can set scope to local (default value) or global # (include notes from remote). #meilisearch: @@ -204,7 +204,7 @@ signToActivityPubGet: true checkActivityPubGetSignature: false # For security reasons, uploading attachments from the intranet is prohibited, -# but exceptions can be made from the following settings. Default value is "undefined". +# but exceptions can be made from the following settings. Default value is "undefined". # Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)). #allowedPrivateNetworks: [ # '127.0.0.1/32' @@ -212,5 +212,9 @@ checkActivityPubGetSignature: false #customMOTD: ['Hello World', 'The sharks rule all', 'Shonks'] +# Prefer these languages for remote notes with language-specific content +# Must be valid language codes according to BCP 47 +#langPref: ['en', 'ja'] + # Upload or download file size limits (bytes) #maxFileSize: 262144000 diff --git a/.config/docker_example.yml b/.config/docker_example.yml index 296237c95..4aa2dc9a1 100644 --- a/.config/docker_example.yml +++ b/.config/docker_example.yml @@ -163,7 +163,7 @@ redis: # ┌───────────────────────────┐ #───┘ MeiliSearch configuration └───────────────────────────── -# You can set scope to local (default value) or global +# You can set scope to local (default value) or global # (include notes from remote). #meilisearch: @@ -261,7 +261,7 @@ signToActivityPubGet: true checkActivityPubGetSignature: false # For security reasons, uploading attachments from the intranet is prohibited, -# but exceptions can be made from the following settings. Default value is "undefined". +# but exceptions can be made from the following settings. Default value is "undefined". # Read changelog to learn more (Improvements of 12.90.0 (2021/09/04)). #allowedPrivateNetworks: [ # '127.0.0.1/32' @@ -269,5 +269,9 @@ checkActivityPubGetSignature: false #customMOTD: ['Hello World', 'The sharks rule all', 'Shonks'] +# Prefer these languages for remote notes with language-specific content +# Must be valid language codes according to BCP 47 +#langPref: ['en', 'ja'] + # Upload or download file size limits (bytes) #maxFileSize: 262144000 diff --git a/.config/example.yml b/.config/example.yml index c037a280b..9af68272d 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -284,6 +284,10 @@ checkActivityPubGetSignature: false #customMOTD: ['Hello World', 'The sharks rule all', 'Shonks'] +# Prefer these languages for remote notes with language-specific content +# Must be valid language codes according to BCP 47 +#langPref: ['en', 'ja'] + # Upload or download file size limits (bytes) #maxFileSize: 262144000 diff --git a/locales/en-US.yml b/locales/en-US.yml index 80e45c42d..d39563086 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1270,6 +1270,7 @@ hemisphere: "Where are you located" withSensitive: "Include notes with sensitive files" userSaysSomethingSensitive: "Post by {name} contains sensitive content" enableHorizontalSwipe: "Swipe to switch tabs" +noLanguage: "No language" loading: "Loading" surrender: "Cancel" gameRetry: "Retry" diff --git a/locales/index.d.ts b/locales/index.d.ts index e407d2119..78f439d45 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5093,6 +5093,10 @@ export interface Locale extends ILocale { * スワイプしてタブを切り替える */ "enableHorizontalSwipe": string; + /** + * 言語なし + */ + "noLanguage": string; /** * 読み込み中 */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 57f52c64b..61764ea7c 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1269,6 +1269,7 @@ hemisphere: "お住まいの地域" withSensitive: "センシティブなファイルを含むノートを表示" userSaysSomethingSensitive: "{name}のセンシティブなファイルを含む投稿" enableHorizontalSwipe: "スワイプしてタブを切り替える" +noLanguage: "言語なし" loading: "読み込み中" surrender: "やめる" gameRetry: "リトライ" diff --git a/packages/backend/migration/1706852162173-add-post-lang.js b/packages/backend/migration/1706852162173-add-post-lang.js new file mode 100644 index 000000000..90e1338f1 --- /dev/null +++ b/packages/backend/migration/1706852162173-add-post-lang.js @@ -0,0 +1,13 @@ +export class AddPostLang1706852162173 { + name = 'AddPostLang1706852162173' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "lang" character varying(10)`); + await queryRunner.query(`ALTER TABLE "note_edit" ADD "lang" character varying(10)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "lang"`); + await queryRunner.query(`ALTER TABLE "note_edit" DROP COLUMN "lang"`); + } +} diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index c99bc7ae0..2fa736951 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -91,6 +91,8 @@ type Source = { customMOTD?: string[]; + langPref?: string[]; + signToActivityPubGet?: boolean; checkActivityPubGetSignature?: boolean; @@ -153,6 +155,7 @@ export type Config = { customMOTD: string[] | undefined; signToActivityPubGet: boolean; checkActivityPubGetSignature: boolean | undefined; + langPref: string[]; version: string; publishTarballInsteadOfProvideRepositoryUrl: boolean; @@ -269,6 +272,7 @@ export function loadConfig(): Config { inboxJobMaxAttempts: config.inboxJobMaxAttempts, proxyRemoteFiles: config.proxyRemoteFiles, customMOTD: config.customMOTD, + langPref: config.langPref ?? ['en', 'ja'], signToActivityPubGet: config.signToActivityPubGet ?? true, checkActivityPubGetSignature: config.checkActivityPubGetSignature, mediaProxy: externalMediaProxy ?? internalMediaProxy, diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 631d7074b..9b652f49c 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -133,6 +133,7 @@ type Option = { createdAt?: Date | null; name?: string | null; text?: string | null; + lang?: string | null; reply?: MiNote | null; renote?: MiNote | null; files?: MiDriveFile[] | null; @@ -603,6 +604,7 @@ export class NoteCreateService implements OnApplicationShutdown { : null, name: data.name, text: data.text, + lang: data.lang, hasPoll: data.poll != null, cw: data.cw ?? null, tags: tags.map(tag => normalizeForSearch(tag)), diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts index 72fc01ae3..846e52fb5 100644 --- a/packages/backend/src/core/NoteEditService.ts +++ b/packages/backend/src/core/NoteEditService.ts @@ -123,6 +123,7 @@ type Option = { createdAt?: Date | null; name?: string | null; text?: string | null; + lang?: string | null; reply?: MiNote | null; renote?: MiNote | null; files?: MiDriveFile[] | null; @@ -429,6 +430,9 @@ export class NoteEditService implements OnApplicationShutdown { if (oldnote.hasPoll !== !!data.poll) { update.hasPoll = !!data.poll; } + if (data.lang !== oldnote.lang) { + update.lang = data.lang; + } const poll = await this.pollsRepository.findOneBy({ noteId: oldnote.id }); @@ -443,6 +447,7 @@ export class NoteEditService implements OnApplicationShutdown { oldText: oldnote.text || undefined, newText: update.text || undefined, cw: update.cw || undefined, + lang: update.lang || undefined, fileIds: undefined, oldDate: exists ? oldnote.updatedAt as Date : this.idService.parse(oldnote.id).date, updatedAt: new Date(), @@ -462,6 +467,7 @@ export class NoteEditService implements OnApplicationShutdown { : null, name: data.name, text: data.text, + lang: data.lang, hasPoll: data.poll != null, cw: data.cw ?? null, tags: tags.map(tag => normalizeForSearch(tag)), diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index a84feffac..3ba85ba0f 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -285,7 +285,7 @@ export class ApRendererService { if (instance && instance.softwareName === 'pleroma') isMastodon = true; } } - + const object: ILike = { type: 'Like', id: `${this.config.url}/likes/${noteReaction.id}`, @@ -451,9 +451,15 @@ export class ApRendererService { _misskey_content: text, source: { content: text, + contentMap: note.lang ? { + [note.lang]: text, + } : undefined, mediaType: 'text/x.misskeymarkdown', }, }), + contentMap: note.lang && content ? { + [note.lang]: content, + } : undefined, _misskey_quote: quote, quoteUrl: quote, quoteUri: quote, @@ -743,9 +749,15 @@ export class ApRendererService { _misskey_content: text, source: { content: text, + contentMap: note.lang ? { + [note.lang]: text, + } : undefined, mediaType: 'text/x.misskeymarkdown', }, }), + contentMap: note.lang && content ? { + [note.lang]: content, + } : undefined, _misskey_quote: quote, quoteUrl: quote, quoteUri: quote, diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 6d9dc86c1..dfeb296b1 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -25,6 +25,7 @@ import { StatusError } from '@/misc/status-error.js'; import { UtilityService } from '@/core/UtilityService.js'; import { bindThis } from '@/decorators.js'; import { checkHttps } from '@/misc/check-https.js'; +import { langs } from '@/misc/langmap.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { isNotNull } from '@/misc/is-not-null.js'; import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; @@ -39,7 +40,7 @@ import { ApMentionService } from './ApMentionService.js'; import { ApQuestionService } from './ApQuestionService.js'; import { ApImageService } from './ApImageService.js'; import type { Resolver } from '../ApResolverService.js'; -import type { IObject, IPost } from '../type.js'; +import type { IObject, IPost, Obj } from '../type.js'; @Injectable() export class ApNoteService { @@ -173,12 +174,17 @@ export class ApNoteService { // テキストのパース let text: string | null = null; - if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') { - text = note.source.content; + let lang: string | null = null; + if (note.source?.mediaType === 'text/x.misskeymarkdown' && (typeof note.source.content === 'string' || note.source.contentMap)) { + const guessed = this.guessLang(note.source); + text = guessed.text; + lang = guessed.lang; } else if (typeof note._misskey_content !== 'undefined') { text = note._misskey_content; - } else if (typeof note.content === 'string') { - text = this.apMfmService.htmlToMfm(note.content, note.tag); + } else if (typeof note.content === 'string' || note.contentMap) { + const guessed = this.guessLang(note); + lang = guessed.lang; + if (guessed.text) text = this.apMfmService.htmlToMfm(guessed.text, note.tag); } const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined); @@ -310,6 +316,7 @@ export class ApNoteService { name: note.name, cw, text, + lang, localOnly: false, visibility, visibleUsers, @@ -400,12 +407,17 @@ export class ApNoteService { // テキストのパース let text: string | null = null; - if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') { - text = note.source.content; + let lang: string | null = null; + if (note.source?.mediaType === 'text/x.misskeymarkdown' && (typeof note.source.content === 'string' || note.source.contentMap)) { + const guessed = this.guessLang(note.source); + text = guessed.text; + lang = guessed.lang; } else if (typeof note._misskey_content !== 'undefined') { text = note._misskey_content; - } else if (typeof note.content === 'string') { - text = this.apMfmService.htmlToMfm(note.content, note.tag); + } else if (typeof note.content === 'string' || note.contentMap) { + const guessed = this.guessLang(note); + lang = guessed.lang; + if (guessed.text) text = this.apMfmService.htmlToMfm(guessed.text, note.tag); } const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined); @@ -537,6 +549,7 @@ export class ApNoteService { name: note.name, cw, text, + lang, localOnly: false, visibility, visibleUsers, @@ -654,4 +667,40 @@ export class ApNoteService { }).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0])); })); } + + @bindThis + private guessLang(source: { contentMap?: Obj | null, content?: string | null }): { lang: string | null, text: string | null } { + // do we have a map? + if (source.contentMap) { + const entries = Object.entries(source.contentMap); + + // only one entry: take that + if (entries.length === 1) { + return { lang: entries[0][0], text: entries[0][1] }; + } + + // did the sender indicate a preferred language? + if (source.content) { + for (const e of entries) { + if (e[1] === source.content) { + return { lang: e[0], text: e[1] }; + } + } + } + + // can we find one of *our* preferred languages? + for (const prefLang of this.config.langPref) { + if (source.contentMap[prefLang]) { + return { lang: prefLang, text: source.contentMap[prefLang] }; + } + } + + // bah, just pick one + return { lang: entries[0][0], text: entries[0][1] }; + } + + // no map, so we don't know the language, just take whatever + // content we got + return { lang: null, text: source.content ?? null }; + } } diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 716515840..c8b4a6ceb 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -21,6 +21,7 @@ export interface IObject { inReplyTo?: any; replies?: ICollection; content?: string | null; + contentMap?: Obj | null; startTime?: Date; endTime?: Date; icon?: any; @@ -114,6 +115,7 @@ export interface IPost extends IObject { type: 'Note' | 'Question' | 'Article' | 'Audio' | 'Document' | 'Image' | 'Page' | 'Video' | 'Event'; source?: { content: string; + contentMap?: Obj | null; mediaType: string; }; _misskey_quote?: string; @@ -128,6 +130,7 @@ export interface IQuestion extends IObject { actor: string; source?: { content: string; + contentMap?: Obj | null; mediaType: string; }; _misskey_quote?: string; diff --git a/packages/backend/src/core/entities/NoteEntityService.ts b/packages/backend/src/core/entities/NoteEntityService.ts index 86a8670f2..465b5c28c 100644 --- a/packages/backend/src/core/entities/NoteEntityService.ts +++ b/packages/backend/src/core/entities/NoteEntityService.ts @@ -17,12 +17,12 @@ import { bindThis } from '@/decorators.js'; import { isNotNull } from '@/misc/is-not-null.js'; import { DebounceLoader } from '@/misc/loader.js'; import { IdService } from '@/core/IdService.js'; +import type { Config } from '@/config.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { ReactionService } from '../ReactionService.js'; import type { UserEntityService } from './UserEntityService.js'; import type { DriveFileEntityService } from './DriveFileEntityService.js'; -import type { Config } from '@/config.js'; @Injectable() export class NoteEntityService implements OnModuleInit { @@ -120,7 +120,7 @@ export class NoteEntityService implements OnModuleInit { followerId: meId, }, }); - + hide = !isFollowing; } else { // フォロワーかどうか @@ -373,6 +373,7 @@ export class NoteEntityService implements OnModuleInit { uri: note.uri ?? undefined, url: note.url ?? undefined, poll: note.hasPoll ? this.populatePoll(note, meId) : undefined, + lang: note.lang, ...(meId && Object.keys(note.reactions).length > 0 ? { myReaction: this.populateMyReaction(note, meId, options?._hint_), } : {}), diff --git a/packages/backend/src/misc/langmap.ts b/packages/backend/src/misc/langmap.ts index 5ff933865..628bbe40d 100644 --- a/packages/backend/src/misc/langmap.ts +++ b/packages/backend/src/misc/langmap.ts @@ -35,6 +35,9 @@ export const langmap = { 'ar-SA': { nativeName: 'العربية (السعودية)', }, + 'ay': { + nativeName: 'Aymar aru', + }, 'ay-BO': { nativeName: 'Aymar aru', }, @@ -44,6 +47,9 @@ export const langmap = { 'az-AZ': { nativeName: 'Azərbaycan dili', }, + 'be': { + nativeName: 'Беларуская', + }, 'be-BY': { nativeName: 'Беларуская', }, @@ -65,6 +71,9 @@ export const langmap = { 'br': { nativeName: 'Brezhoneg', }, + 'bs': { + nativeName: 'Bosanski', + }, 'bs-BA': { nativeName: 'Bosanski', }, @@ -77,7 +86,7 @@ export const langmap = { 'cak': { nativeName: 'Maya Kaqchikel', }, - 'ck-US': { + 'chr': { nativeName: 'ᏣᎳᎩ (tsalagi)', }, 'cs': { @@ -152,9 +161,6 @@ export const langmap = { 'en-ZA': { nativeName: 'English (South Africa)', }, - 'en@pirate': { - nativeName: 'English (Pirate)', - }, 'eo': { nativeName: 'Esperanto', }, @@ -248,6 +254,9 @@ export const langmap = { 'fr-CH': { nativeName: 'Français (Suisse)', }, + 'fy': { + nativeName: 'Frysk', + }, 'fy-NL': { nativeName: 'Frysk', }, @@ -266,16 +275,22 @@ export const langmap = { 'gl-ES': { nativeName: 'Galego', }, + 'gn': { + nativeName: 'Avañe\'ẽ', + }, 'gn-PY': { nativeName: 'Avañe\'ẽ', }, + 'gu': { + nativeName: 'ગુજરાતી', + }, 'gu-IN': { nativeName: 'ગુજરાતી', }, 'gv': { nativeName: 'Gaelg', }, - 'gx-GR': { + 'grc': { nativeName: 'Ἑλληνική ἀρχαία', }, 'he': { @@ -338,12 +353,21 @@ export const langmap = { 'ja-JP': { nativeName: '日本語 (日本)', }, + 'jv': { + nativeName: 'Basa Jawa', + }, 'jv-ID': { nativeName: 'Basa Jawa', }, + 'ka': { + nativeName: 'ქართული', + }, 'ka-GE': { nativeName: 'ქართული', }, + 'kk': { + nativeName: 'Қазақша', + }, 'kk-KZ': { nativeName: 'Қазақша', }, @@ -371,6 +395,9 @@ export const langmap = { 'ko-KR': { nativeName: '한국어 (한국)', }, + 'ku': { + nativeName: 'Kurdî', + }, 'ku-TR': { nativeName: 'Kurdî', }, @@ -386,6 +413,9 @@ export const langmap = { 'lb': { nativeName: 'Lëtzebuergesch', }, + 'li': { + nativeName: 'Lèmbörgs', + }, 'li-NL': { nativeName: 'Lèmbörgs', }, @@ -404,6 +434,9 @@ export const langmap = { 'mai': { nativeName: 'मैथिली, মৈথিলী', }, + 'mg': { + nativeName: 'Malagasy', + }, 'mg-MG': { nativeName: 'Malagasy', }, @@ -419,6 +452,9 @@ export const langmap = { 'ml-IN': { nativeName: 'മലയാളം', }, + 'mn': { + nativeName: 'Монгол', + }, 'mn-MN': { nativeName: 'Монгол', }, @@ -443,6 +479,9 @@ export const langmap = { 'my': { nativeName: 'ဗမာစကာ', }, + 'nan': { + nativeName: '閩南語', + }, 'no': { nativeName: 'Norsk', }, @@ -467,12 +506,18 @@ export const langmap = { 'nl-NL': { nativeName: 'Nederlands (Nederland)', }, + 'nn': { + nativeName: 'Norsk (nynorsk)', + }, 'nn-NO': { nativeName: 'Norsk (nynorsk)', }, 'oc': { nativeName: 'Occitan', }, + 'or': { + nativeName: 'ଓଡ଼ିଆ', + }, 'or-IN': { nativeName: 'ଓଡ଼ିଆ', }, @@ -488,6 +533,9 @@ export const langmap = { 'pl-PL': { nativeName: 'Polski', }, + 'ps': { + nativeName: 'پښتو', + }, 'ps-AF': { nativeName: 'پښتو', }, @@ -500,9 +548,15 @@ export const langmap = { 'pt-PT': { nativeName: 'Português (Portugal)', }, + 'qu': { + nativeName: 'Qhichwa', + }, 'qu-PE': { nativeName: 'Qhichwa', }, + 'rm': { + nativeName: 'Rumantsch', + }, 'rm-CH': { nativeName: 'Rumantsch', }, @@ -518,15 +572,24 @@ export const langmap = { 'ru-RU': { nativeName: 'Русский', }, + 'sa': { + nativeName: 'संस्कृतम्', + }, 'sa-IN': { nativeName: 'संस्कृतम्', }, + 'se': { + nativeName: 'Davvisámegiella', + }, 'se-NO': { nativeName: 'Davvisámegiella', }, 'sh': { nativeName: 'српскохрватски', }, + 'si': { + nativeName: 'සිංහල', + }, 'si-LK': { nativeName: 'සිංහල', }, @@ -542,6 +605,9 @@ export const langmap = { 'sl-SI': { nativeName: 'Slovenščina', }, + 'so': { + nativeName: 'Soomaaliga', + }, 'so-SO': { nativeName: 'Soomaaliga', }, @@ -602,12 +668,18 @@ export const langmap = { 'tlh': { nativeName: 'tlhIngan-Hol', }, + 'tok': { + nativeName: 'Toki Pona', + }, 'tr': { nativeName: 'Türkçe', }, 'tr-TR': { nativeName: 'Türkçe', }, + 'tt': { + nativeName: 'татарча', + }, 'tt-RU': { nativeName: 'татарча', }, @@ -635,6 +707,9 @@ export const langmap = { 'vi-VN': { nativeName: 'Tiếng Việt', }, + 'xh': { + nativeName: 'isiXhosa', + }, 'xh-ZA': { nativeName: 'isiXhosa', }, @@ -644,6 +719,9 @@ export const langmap = { 'yi-DE': { nativeName: 'ייִדיש (German)', }, + 'yue': { + nativeName: '粵語', + }, 'zh': { nativeName: '中文', }, @@ -665,7 +743,16 @@ export const langmap = { 'zh-TW': { nativeName: '中文(台灣)', }, + 'zu': { + nativeName: 'isiZulu', + }, 'zu-ZA': { nativeName: 'isiZulu', }, }; + +export const langs: string[] = [ + ...(Object.keys(langmap).filter(tag => tag.indexOf('-') < 0)), + 'zh-Hans', + 'zh-Hant', +]; diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index b11e2ec62..c1cf45e5f 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -61,6 +61,12 @@ export class MiNote { }) public text: string | null; + @Column('varchar', { + length: 10, + nullable: true, + }) + public lang: string | null; + @Column('varchar', { length: 256, nullable: true, }) diff --git a/packages/backend/src/models/NoteEdit.ts b/packages/backend/src/models/NoteEdit.ts index 9ec989dd5..942ba6a0f 100644 --- a/packages/backend/src/models/NoteEdit.ts +++ b/packages/backend/src/models/NoteEdit.ts @@ -1,4 +1,4 @@ -import { Entity, JoinColumn, Column, ManyToOne, PrimaryColumn, Index } from "typeorm"; +import { Entity, JoinColumn, Column, ManyToOne, PrimaryColumn, Index } from 'typeorm'; import { id } from './util/id.js'; import { MiNote } from './Note.js'; import type { MiDriveFile } from './DriveFile.js'; @@ -11,46 +11,52 @@ export class NoteEdit { @Index() @Column({ ...id(), - comment: "The ID of note.", + comment: 'The ID of note.', }) - public noteId: MiNote["id"]; + public noteId: MiNote['id']; @ManyToOne((type) => MiNote, { - onDelete: "CASCADE", + onDelete: 'CASCADE', }) @JoinColumn() public note: MiNote | null; - @Column("text", { + @Column('text', { nullable: true, }) public oldText: string | null; - @Column("text", { + @Column('text', { nullable: true, }) public newText: string | null; - @Column("varchar", { + @Column('varchar', { length: 512, nullable: true, }) public cw: string | null; + @Column('varchar', { + length: 10, + nullable: true, + }) + public lang: string | null; + @Column({ ...id(), array: true, - default: "{}", + default: '{}', }) - public fileIds: MiDriveFile["id"][]; + public fileIds: MiDriveFile['id'][]; - @Column("timestamp with time zone", { - comment: "The updated date of the Note.", + @Column('timestamp with time zone', { + comment: 'The updated date of the Note.', }) public updatedAt: Date; - @Column("timestamp with time zone", { - comment: "The old date from before the edit", + @Column('timestamp with time zone', { + comment: 'The old date from before the edit', }) public oldDate: Date; } diff --git a/packages/backend/src/models/json-schema/note.ts b/packages/backend/src/models/json-schema/note.ts index bb4ccc7ee..5a7a1deda 100644 --- a/packages/backend/src/models/json-schema/note.ts +++ b/packages/backend/src/models/json-schema/note.ts @@ -2,6 +2,7 @@ * SPDX-FileCopyrightText: syuilo and misskey-project * SPDX-License-Identifier: AGPL-3.0-only */ +import { langs } from '@/misc/langmap.js'; export const packedNoteSchema = { type: 'object', @@ -26,6 +27,11 @@ export const packedNoteSchema = { type: 'string', optional: false, nullable: true, }, + lang: { + type: 'string', + enum: langs, + nullable: true, + }, cw: { type: 'string', optional: true, nullable: true, diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index 06edb2857..13f597e30 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -382,7 +382,7 @@ export default class extends Endpoint { // eslint- updates.backgroundUrl = null; updates.backgroundBlurhash = null; } - + if (ps.avatarDecorations) { const decorations = await this.avatarDecorationService.getAll(true); const [myRoles, myPolicies] = await Promise.all([this.roleService.getUserRoles(user.id), this.roleService.getUserPolicies(user.id)]); diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts index 95ebda2f2..56fa19891 100644 --- a/packages/backend/src/server/api/endpoints/notes/create.ts +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -20,6 +20,7 @@ import { isPureRenote } from '@/misc/is-pure-renote.js'; import { MetaService } from '@/core/MetaService.js'; import { UtilityService } from '@/core/UtilityService.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { langs } from '@/misc/langmap.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -148,6 +149,7 @@ export const paramDef = { visibleUserIds: { type: 'array', uniqueItems: true, items: { type: 'string', format: 'misskey:id', } }, + lang: { type: 'string', enum: langs, nullable: true, maxLength: 10 }, cw: { type: 'string', nullable: true, minLength: 1, maxLength: 500 }, localOnly: { type: 'boolean', default: false }, reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, @@ -384,6 +386,7 @@ export default class extends Endpoint { // eslint- expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, } : undefined, text: ps.text ?? undefined, + lang: ps.lang, reply, renote, cw: ps.cw, diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts index 3caeda288..f34878c08 100644 --- a/packages/backend/src/server/api/endpoints/notes/edit.ts +++ b/packages/backend/src/server/api/endpoints/notes/edit.ts @@ -13,6 +13,7 @@ import { NoteEditService } from '@/core/NoteEditService.js'; import { DI } from '@/di-symbols.js'; import { isPureRenote } from '@/misc/is-pure-renote.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; +import { langs } from '@/misc/langmap.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -197,6 +198,7 @@ export const paramDef = { format: 'misskey:id', }, }, + lang: { type: 'string', enum: langs, nullable: true, maxLength: 10 }, cw: { type: 'string', nullable: true, minLength: 1, maxLength: 500 }, localOnly: { type: 'boolean', default: false }, reactionAcceptance: { type: 'string', nullable: true, enum: [null, 'likeOnly', 'likeOnlyForRemote', 'nonSensitiveOnly', 'nonSensitiveOnlyForLocalLikeOnlyForRemote'], default: null }, @@ -436,6 +438,7 @@ export default class extends Endpoint { // eslint- expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, } : undefined, text: ps.text ?? undefined, + lang: ps.lang, reply, renote, cw: ps.cw, diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 9a667c311..a28f50c6c 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only

- +

@@ -65,6 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only v-if="appearNote.text" :parsedNodes="parsed" :text="appearNote.text" + :lang="appearNote.lang" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" @@ -76,7 +77,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: - +
{{ i18n.ts._animatedMFM.play }} @@ -217,6 +218,7 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog.js'; import { shouldCollapsed } from '@/scripts/collapsed.js'; import { useRouter } from '@/router/supplier.js'; import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; +import { miLocalStorage } from '@/local-storage.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -305,11 +307,12 @@ const renoteCollapsed = ref( defaultStore.state.collapseRenotes && isRenote && ( ($i && ($i.id === note.value.userId || $i.id === appearNote.value.userId)) || // `||` must be `||`! See https://github.com/misskey-dev/misskey/issues/13131 (appearNote.value.myReaction != null) - ) + ), ); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null); const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false); +const nativeLang = ref(miLocalStorage.getItem('lang') ?? window.navigator.language); /* Overload FunctionにLintが対応していないのでコメントアウト function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array | undefined | null, checkOnly: true): boolean; @@ -416,7 +419,7 @@ function boostVisibility() { } } -function renote(visibility: Visibility, localOnly: boolean = false) { +function renote(visibility: Visibility, localOnly = false) { pleaseLogin(); showMovedDialog(); diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 3d15f69f7..39c5db017 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -68,7 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only

- +

@@ -78,6 +78,7 @@ SPDX-License-Identifier: AGPL-3.0-only v-if="appearNote.text" :parsedNodes="parsed" :text="appearNote.text" + :lang="appearNote.lang" :author="appearNote.user" :nyaize="'respect'" :emojiUrls="appearNote.emojis" @@ -90,7 +91,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.tsx.translatedFrom({ x: translation.sourceLang }) }}: - +
{{ i18n.ts._animatedMFM.play }} @@ -258,6 +259,7 @@ import MkPagination, { type Paging } from '@/components/MkPagination.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; +import { miLocalStorage } from '@/local-storage.js'; const props = defineProps<{ note: Misskey.entities.Note; @@ -321,6 +323,7 @@ const replies = ref([]); const quotes = ref([]); const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || (appearNote.value.visibility === 'followers' && appearNote.value.userId === $i?.id)); const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.like : null); +const nativeLang = ref(miLocalStorage.getItem('lang') ?? window.navigator.language); watch(() => props.expandAllCws, (expandAllCws) => { if (expandAllCws !== showContent.value) showContent.value = expandAllCws; @@ -438,7 +441,7 @@ function boostVisibility() { } } -function renote(visibility: Visibility, localOnly: boolean = false) { +function renote(visibility: Visibility, localOnly = false) { pleaseLogin(); showMovedDialog(); diff --git a/packages/frontend/src/components/MkNoteSub.vue b/packages/frontend/src/components/MkNoteSub.vue index 37811dd52..6c00fab92 100644 --- a/packages/frontend/src/components/MkNoteSub.vue +++ b/packages/frontend/src/components/MkNoteSub.vue @@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only

- +

@@ -281,7 +281,7 @@ function boostVisibility() { } } -function renote(visibility: Visibility, localOnly: boolean = false) { +function renote(visibility: Visibility, localOnly = false) { pleaseLogin(); showMovedDialog(); diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index d9e50fbb7..22800aec4 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -32,6 +32,10 @@ SPDX-License-Identifier: AGPL-3.0-only {{ channel.name }} +
{{ i18n.ts.notSpecifiedMentionWarning }} - - +
-