Merge branch 'develop' into mahjong

This commit is contained in:
zyoshoka 2025-04-18 19:31:12 +09:00
commit ac4596974d
No known key found for this signature in database
115 changed files with 3626 additions and 2821 deletions

View File

@ -1,14 +1,28 @@
## Unreleased ## 2025.4.1
### General ### General
- - Enhance: チャットの新規メッセージをプッシュ通知するように
### Client ### Client
- - Feat: チャットウィジェットを追加
- Feat: デッキにチャットカラムを追加
- Enhance: Unicode絵文字をslugから入力する際に`:ok:`のように最後の`:`を入力したあとにUnicode絵文字に変換できるように
- Enhance: コントロールパネルでジョブキューをクリアできるように
- Enhance: テーマでページヘッダーの色を変更できるように
- Enhance: デザインのブラッシュアップ
- Fix: ログアウトした際に処理が終了しない問題を修正
- Fix: 自動バックアップが設定されている環境でログアウト直前に設定をバックアップするように
- Fix: フォルダを開いた状態でメニューからアップロードしてもルートフォルダにアップロードされる問題を修正 #15836
- Fix: タイムラインのスクロール位置を記憶するように修正
- Fix: ノートの直後のノートを表示する機能で表示が逆順になっていた問題を修正 #15841
- Fix: アカウントの移行時にアンテナのフィルターのユーザが更新されない問題を修正 #15843
### Server ### Server
- - Enhance: フォローしているユーザーならフォロワー限定投稿のノートでもアンテナで検知できるように
(Cherry-picked from https://github.com/yojo-art/cherrypick/pull/568 and https://github.com/team-shahu/misskey/pull/38)
- Fix: システムアカウントの名前がサーバー名と同期されない問題を修正
- Fix: 大文字を含むユーザの URL で紹介された場合に 404 エラーを返す問題 #15813
- Fix: リードレプリカ設定時にレコードの追加・更新・削除を伴うクエリを発行した際はmasterードで実行されるように調整( #10897 )
## 2025.4.0 ## 2025.4.0
@ -42,7 +56,7 @@
- プラグイン、テーマ、クライアントに追加されたすべてのアカウント情報も含まれるようになりました - プラグイン、テーマ、クライアントに追加されたすべてのアカウント情報も含まれるようになりました
- 自動で設定データをサーバーにバックアップできるように - 自動で設定データをサーバーにバックアップできるように
- 設定→設定のプロファイル→自動バックアップ で有効にできます - 設定→設定のプロファイル→自動バックアップ で有効にできます
- 新しいデバイスからログインしたり、ブラウザから設定データが消えてしまったときに自動で復元されます(復元をスキップすることも可能) - ログインしたとき、ブラウザから設定データが消えてしまったときに自動で復元されます(復元をスキップすることも可能)
- 任意の設定項目をデバイス間で同期できるように - 任意の設定項目をデバイス間で同期できるように
- 設定項目の「...」メニュー→「デバイス間で同期」 - 設定項目の「...」メニュー→「デバイス間で同期」
- 同期をオンにした際にサーバーに保存された値とローカルの値が競合する場合はどちらを優先するか選択できます - 同期をオンにした際にサーバーに保存された値とローカルの値が競合する場合はどちらを優先するか選択できます
@ -51,7 +65,7 @@
- アカウントごとに設定値が分離される設定とそうでないクライアント設定が混在していた(かつ分離するかどうかを設定不可だった)のを、基本的に一律でクライアント全体に適用されるようにし、個別でアカウントごとに異なる設定を行えるように - アカウントごとに設定値が分離される設定とそうでないクライアント設定が混在していた(かつ分離するかどうかを設定不可だった)のを、基本的に一律でクライアント全体に適用されるようにし、個別でアカウントごとに異なる設定を行えるように
- 設定項目の「...」メニュー→「アカウントで上書き」をオンにすることで、設定値をそのアカウントでだけ適用するようにできます - 設定項目の「...」メニュー→「アカウントで上書き」をオンにすることで、設定値をそのアカウントでだけ適用するようにできます
- ログアウトすると設定データもブラウザから消去されるようになりプライバシーが向上しました - ログアウトすると設定データもブラウザから消去されるようになりプライバシーが向上しました
- 再度ログインすればサーバーのバックアップから設定データを復元可能です - バックアップを有効にしている場合、ログインした後にバックアップから設定データを復元可能です
- エクスポートした設定データを他のサーバーでインポートして適用すること(設定の持ち運び)が可能になりました - エクスポートした設定データを他のサーバーでインポートして適用すること(設定の持ち運び)が可能になりました
- 設定情報の移行は自動で行われますが、何らかの理由で失敗した場合、設定→その他→旧設定情報を移行 で再試行可能です - 設定情報の移行は自動で行われますが、何らかの理由で失敗した場合、設定→その他→旧設定情報を移行 で再試行可能です
- 過去に作成されたバックアップデータとは現在互換性がありませんのでご注意ください - 過去に作成されたバックアップデータとは現在互換性がありませんのでご注意ください

View File

@ -356,7 +356,7 @@ banner: "Bàner"
displayOfSensitiveMedia: "Visualització de contingut sensible" displayOfSensitiveMedia: "Visualització de contingut sensible"
whenServerDisconnected: "Quan es perdi la connexió al servidor" whenServerDisconnected: "Quan es perdi la connexió al servidor"
disconnectedFromServer: "Desconnectat pel servidor" disconnectedFromServer: "Desconnectat pel servidor"
reload: "Actualitza" reload: "Actualitzar"
doNothing: "Ignora" doNothing: "Ignora"
reloadConfirm: "Vols recarregar?" reloadConfirm: "Vols recarregar?"
watch: "Veure" watch: "Veure"
@ -708,7 +708,7 @@ notificationSetting: "Paràmetres de notificacions"
notificationSettingDesc: "Selecciona els tipus de notificacions que es mostraran" notificationSettingDesc: "Selecciona els tipus de notificacions que es mostraran"
useGlobalSetting: "Fer servir la configuració global" useGlobalSetting: "Fer servir la configuració global"
useGlobalSettingDesc: "Si s'activa, es farà servir la configuració de notificacions del teu comte. Si no s'activa es poden fer configuracions individuals." useGlobalSettingDesc: "Si s'activa, es farà servir la configuració de notificacions del teu comte. Si no s'activa es poden fer configuracions individuals."
other: "Altre" other: "Altres"
regenerateLoginToken: "Regenerar clau de seguretat d'inici de sessió" regenerateLoginToken: "Regenerar clau de seguretat d'inici de sessió"
regenerateLoginTokenDescription: "Regenera la clau de seguretat que es fa servir internament durant l'inici de sessió. Normalment aquesta acció no és necessària. Si es regenera es tancarà la sessió a tots els dispositius amb una sessió activa." regenerateLoginTokenDescription: "Regenera la clau de seguretat que es fa servir internament durant l'inici de sessió. Normalment aquesta acció no és necessària. Si es regenera es tancarà la sessió a tots els dispositius amb una sessió activa."
theKeywordWhenSearchingForCustomEmoji: "Cercar un emoji personalitzat " theKeywordWhenSearchingForCustomEmoji: "Cercar un emoji personalitzat "
@ -979,6 +979,7 @@ document: "Documentació"
numberOfPageCache: "Nombre de pàgines a la memòria cau" numberOfPageCache: "Nombre de pàgines a la memòria cau"
numberOfPageCacheDescription: "Incrementant aquest nombre farà que millori l'experiència de l'usuari, però es farà servir més memòria al dispositiu de l'usuari." numberOfPageCacheDescription: "Incrementant aquest nombre farà que millori l'experiència de l'usuari, però es farà servir més memòria al dispositiu de l'usuari."
logoutConfirm: "Vols sortir?" logoutConfirm: "Vols sortir?"
logoutWillClearClientData: "En tancar la sessió, la informació del client al navegador s'esborrarà. Per garantir que la informació de configuració es pugui restaurar en tornar a iniciar sessió activa la còpia de seguretat automàtica de la configuració."
lastActiveDate: "Fet servir per última vegada" lastActiveDate: "Fet servir per última vegada"
statusbar: "Barra d'estat" statusbar: "Barra d'estat"
pleaseSelect: "Selecciona una opció" pleaseSelect: "Selecciona una opció"
@ -1334,14 +1335,14 @@ postForm: "Formulari de publicació"
textCount: "Nombre de caràcters " textCount: "Nombre de caràcters "
information: "Informació" information: "Informació"
chat: "Xat" chat: "Xat"
migrateOldSettings: "Migració de la configuració antiga " migrateOldSettings: "Migrar la configuració anterior"
migrateOldSettings_description: "Normalment això es fa automàticament, però si la transició no es fa, el procés es pot iniciar manualment. S'esborrarà la configuració actual." migrateOldSettings_description: "Normalment això es fa automàticament, però si la transició no es fa, el procés es pot iniciar manualment. S'esborrarà la configuració actual."
compress: "Comprimir " compress: "Comprimir "
right: "Dreta" right: "Dreta"
bottom: "A baix " bottom: "A baix "
top: "A dalt " top: "A dalt "
embed: "Incrustar" embed: "Incrustar"
settingsMigrating: "Estem fent la migració de la teva configuració. Si us plau espera un moment... (També pots fer la migració més tard i manualment anant a Preferències → Altres configuracions → Migrar configuració antiga)" settingsMigrating: "Estem migrant la teva configuració. Si us plau espera un moment... (També pots fer la migració més tard, manualment, anant a Preferències → Altres → Migrar configuració antiga)"
readonly: "Només lectura" readonly: "Només lectura"
goToDeck: "Tornar al tauler" goToDeck: "Tornar al tauler"
_chat: _chat:
@ -1359,7 +1360,7 @@ _chat:
noInvitations: "No tens cap invitació " noInvitations: "No tens cap invitació "
history: "Historial de converses " history: "Historial de converses "
noHistory: "No hi ha un registre previ" noHistory: "No hi ha un registre previ"
noRooms: "No hi ha habitacions" noRooms: "No hi ha cap sala"
inviteUser: "Invitar usuaris" inviteUser: "Invitar usuaris"
sentInvitations: "Enviar invitacions" sentInvitations: "Enviar invitacions"
join: "Afegir-se " join: "Afegir-se "
@ -1417,8 +1418,8 @@ _settings:
makeEveryTextElementsSelectable_description: "L'activació pot reduir la usabilitat en determinades ocasions." makeEveryTextElementsSelectable_description: "L'activació pot reduir la usabilitat en determinades ocasions."
useStickyIcons: "Utilitza icones fixes" useStickyIcons: "Utilitza icones fixes"
showNavbarSubButtons: "Mostrar sub botons a la barra de navegació " showNavbarSubButtons: "Mostrar sub botons a la barra de navegació "
ifOn: "Quan s'encén " ifOn: "Quan s'activa"
ifOff: "Quan s'apaga " ifOff: "Quan es desactiva"
enableSyncThemesBetweenDevices: "Sincronitzar els temes instal·lats entre dispositius" enableSyncThemesBetweenDevices: "Sincronitzar els temes instal·lats entre dispositius"
_chat: _chat:
showSenderName: "Mostrar el nom del remitent" showSenderName: "Mostrar el nom del remitent"
@ -1604,7 +1605,7 @@ _accountMigration:
moveTo: "Migrar aquest compte a un altre" moveTo: "Migrar aquest compte a un altre"
moveToLabel: "Compte al qual es vol migrar:" moveToLabel: "Compte al qual es vol migrar:"
moveCannotBeUndone: "Les migracions dels comptes no es poden desfer." moveCannotBeUndone: "Les migracions dels comptes no es poden desfer."
moveAccountDescription: "Això migrarà la teva compte a un altre diferent.\n ・Els seguidors d'aquest compte és passaran al compte nou de forma automàtica\n ・Es deixaran de seguir a tots els usuaris que es segueixen actualment en aquest compte\n ・No es poden crear notes noves, etc. en aquest compte\n\nSi bé la migració de seguidors es automàtica, has de preparar alguns pasos manualment per migrar la llista d'usuaris que segueixes. Per fer això has d'exportar els seguidors que després importaraes al compte nou mitjançant el menú de configuració. El mateix procediment s'ha de seguir per less teves llistes i els teus usuaris silenciats i bloquejats.\n\n(Aquesta explicació s'aplica a Misskey v13.12.0 i posteriors. Altres aplicacions, com Mastodon, poden funcionar diferent.)" moveAccountDescription: "Això migrarà el teu compte a un altre diferent.\n ・Els seguidors d'aquest compte és passaran al compte nou de forma automàtica\n ・Es deixaran de seguir a tots els usuaris que es segueixen actualment en aquest compte\n ・No es poden crear notes noves, etc. en aquest compte\n\nSi bé la migració de seguidors es automàtica, has de preparar alguns pasos manualment per migrar la llista d'usuaris que segueixes. Per fer això has d'exportar els seguidors que després importaraes al compte nou mitjançant el menú de configuració. El mateix procediment s'ha de seguir per less teves llistes i els teus usuaris silenciats i bloquejats.\n\n(Aquesta explicació s'aplica a Misskey v13.12.0 i posteriors. Altres aplicacions, com Mastodon, poden funcionar diferent.)"
moveAccountHowTo: "Per fer la migració, primer has de crear un àlies per aquest compte al compte al qual vols migrar.\nDesprés de crear l'àlies, introdueix el compte al qual vols migrar amb el format següent: @nomusuari@servidor.exemple.com" moveAccountHowTo: "Per fer la migració, primer has de crear un àlies per aquest compte al compte al qual vols migrar.\nDesprés de crear l'àlies, introdueix el compte al qual vols migrar amb el format següent: @nomusuari@servidor.exemple.com"
startMigration: "Migrar" startMigration: "Migrar"
migrationConfirm: "Vols migrar aquest compte a {account}? Una vegada comenci la migració no es podrà parar O fer marxa enrere i no podràs tornar a fer servir aquest compte mai més." migrationConfirm: "Vols migrar aquest compte a {account}? Una vegada comenci la migració no es podrà parar O fer marxa enrere i no podràs tornar a fer servir aquest compte mai més."
@ -2368,6 +2369,7 @@ _widgets:
chooseList: "Tria una llista" chooseList: "Tria una llista"
clicker: "Clicker" clicker: "Clicker"
birthdayFollowings: "Usuaris que fan l'aniversari avui" birthdayFollowings: "Usuaris que fan l'aniversari avui"
chat: "Xat"
_cw: _cw:
hide: "Amagar" hide: "Amagar"
show: "Carregar més" show: "Carregar més"
@ -2634,6 +2636,7 @@ _deck:
mentions: "Mencions" mentions: "Mencions"
direct: "Publicacions directes" direct: "Publicacions directes"
roleTimeline: "Línia de temps dels rols" roleTimeline: "Línia de temps dels rols"
chat: "Xat"
_dialog: _dialog:
charactersExceeded: "Has arribat al màxim de caràcters! Actualment és {current} de {max}" charactersExceeded: "Has arribat al màxim de caràcters! Actualment és {current} de {max}"
charactersBelow: "Ets per sota del mínim de caràcters! Actualment és {current} de {min}" charactersBelow: "Ets per sota del mínim de caràcters! Actualment és {current} de {min}"

View File

@ -2368,6 +2368,7 @@ _widgets:
chooseList: "Liste auswählen" chooseList: "Liste auswählen"
clicker: "Klickzähler" clicker: "Klickzähler"
birthdayFollowings: "Nutzer, die heute Geburtstag haben" birthdayFollowings: "Nutzer, die heute Geburtstag haben"
chat: "Chat"
_cw: _cw:
hide: "Inhalt verbergen" hide: "Inhalt verbergen"
show: "Inhalt anzeigen" show: "Inhalt anzeigen"
@ -2634,6 +2635,7 @@ _deck:
mentions: "Erwähnungen" mentions: "Erwähnungen"
direct: "Direktnachrichten" direct: "Direktnachrichten"
roleTimeline: "Rollenchronik" roleTimeline: "Rollenchronik"
chat: "Chat"
_dialog: _dialog:
charactersExceeded: "Maximallänge überschritten! Momentan {current} von {max}" charactersExceeded: "Maximallänge überschritten! Momentan {current} von {max}"
charactersBelow: "Minimallänge unterschritten! Momentan {current} von {min}" charactersBelow: "Minimallänge unterschritten! Momentan {current} von {min}"

View File

@ -2368,6 +2368,7 @@ _widgets:
chooseList: "Select a list" chooseList: "Select a list"
clicker: "Clicker" clicker: "Clicker"
birthdayFollowings: "Today's Birthdays" birthdayFollowings: "Today's Birthdays"
chat: "Chat"
_cw: _cw:
hide: "Hide" hide: "Hide"
show: "Show content" show: "Show content"
@ -2634,6 +2635,7 @@ _deck:
mentions: "Mentions" mentions: "Mentions"
direct: "Direct notes" direct: "Direct notes"
roleTimeline: "Role Timeline" roleTimeline: "Role Timeline"
chat: "Chat"
_dialog: _dialog:
charactersExceeded: "You've exceeded the maximum character limit! Currently at {current} of {max}." charactersExceeded: "You've exceeded the maximum character limit! Currently at {current} of {max}."
charactersBelow: "You're below the minimum character limit! Currently at {current} of {min}." charactersBelow: "You're below the minimum character limit! Currently at {current} of {min}."

View File

@ -301,6 +301,7 @@ uploadFromUrlMayTakeTime: "Subir el fichero puede tardar un tiempo."
explore: "Explorar" explore: "Explorar"
messageRead: "Ya leído" messageRead: "Ya leído"
noMoreHistory: "El historial se ha acabado" noMoreHistory: "El historial se ha acabado"
startChat: "Nuevo Chat"
nUsersRead: "Leído por {n} personas" nUsersRead: "Leído por {n} personas"
agreeTo: "De acuerdo con {0}" agreeTo: "De acuerdo con {0}"
agree: "De acuerdo." agree: "De acuerdo."
@ -694,6 +695,7 @@ userSaysSomethingAbout: "{name} dijo algo sobre {word}"
makeActive: "Activar" makeActive: "Activar"
display: "Apariencia" display: "Apariencia"
copy: "Copiar" copy: "Copiar"
copiedToClipboard: "Texto copiado al portapapeles"
metrics: "Métricas" metrics: "Métricas"
overview: "Resumen" overview: "Resumen"
logs: "Registros" logs: "Registros"
@ -1293,18 +1295,55 @@ passkeyVerificationFailed: "La verificación de la clave de acceso ha fallado."
passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verificación de la clave de acceso ha sido satisfactoria pero se ha deshabilitado el inicio de sesión sin contraseña." passkeyVerificationSucceededButPasswordlessLoginDisabled: "La verificación de la clave de acceso ha sido satisfactoria pero se ha deshabilitado el inicio de sesión sin contraseña."
messageToFollower: "Mensaje a seguidores" messageToFollower: "Mensaje a seguidores"
target: "Para" target: "Para"
prohibitedWordsForNameOfUser: "Palabras prohibidas para nombres de usuario"
prohibitedWordsForNameOfUserDescription: "Si alguna de las cadenas de esta lista está incluida en el nombre del usuario, el nombre será denegado. Los usuarios con privilegios de moderador no se ven afectados por esta restricción."
yourNameContainsProhibitedWords: "Tu nombre contiene palabras prohibidas"
yourNameContainsProhibitedWordsDescription: "Si deseas usar este nombre, por favor contacta con tu administrador/a de tu servidor"
lockdown: "Bloqueo"
pleaseSelectAccount: "Seleccione una cuenta, por favor."
availableRoles: "Roles disponibles "
acknowledgeNotesAndEnable: "Activar después de comprender las precauciones"
federationSpecified: "Este servidor opera en una federación de listas blancas. No puede interactuar con otros servidores que no sean los especificados por el administrador." federationSpecified: "Este servidor opera en una federación de listas blancas. No puede interactuar con otros servidores que no sean los especificados por el administrador."
federationDisabled: "La federación está desactivada en este servidor. No puede interactuar con usuarios de otros servidores" federationDisabled: "La federación está desactivada en este servidor. No puede interactuar con usuarios de otros servidores"
preferences: "Preferencias" preferences: "Preferencias"
postForm: "Formulario" postForm: "Formulario"
information: "Información" information: "Información"
right: "Derecha"
bottom: "Abajo"
top: "Arriba"
embed: "Insertar"
settingsMigrating: "La configuración está siendo migrada, por favor espera un momento... (También puedes migrar manualmente más tarde yendo a Ajustes otros migrar configuración antigua"
readonly: "Solo Lectura"
_chat: _chat:
noMessagesYet: "Aún no hay mensajes"
newMessage: "Mensajes nuevos"
individualChat: "Chat individual"
individualChat_description: "Mantén una conversación privada con otra persona."
invitations: "Invitar" invitations: "Invitar"
noHistory: "No hay datos en el historial" noHistory: "No hay datos en el historial"
members: "Miembros" members: "Miembros"
home: "Inicio" home: "Inicio"
send: "Enviar" send: "Enviar"
chatNotAvailableInOtherAccount: "La función de chat está desactivada para el otro usuario."
cannotChatWithTheUser: "No se puede iniciar un chat con este usuario"
cannotChatWithTheUser_description: "El chat no está disponible o la otra parte no ha habilitado el chat."
chatWithThisUser: "Chatear"
thisUserAllowsChatOnlyFromFollowers: "Este usuario sólo acepta chats de seguidores."
thisUserAllowsChatOnlyFromFollowing: "Este usuario sólo acepta chats de los usuarios a los que sigue."
thisUserAllowsChatOnlyFromMutualFollowing: "Este usuario sólo acepta chats de usuarios que son seguidores mutuos."
thisUserNotAllowedChatAnyone: "Este usuario no acepta chats de nadie."
chatAllowedUsers: "A quién permitir chatear."
chatAllowedUsers_note: "Puedes chatear con cualquier persona a la que hayas enviado un mensaje de chat, independientemente de esta configuración."
_chatAllowedUsers:
everyone: "Todos"
followers: "Sólo sus propios seguidores."
following: "Solo usuarios que sigues"
mutual: "Solo seguidores mutuos"
none: "Nadie"
_emojiPalette:
palettes: "Paleta\n"
_settings: _settings:
api: "API"
webhook: "Webhook" webhook: "Webhook"
_accountSettings: _accountSettings:
requireSigninToViewContents: "Se requiere iniciar sesión para ver el contenido" requireSigninToViewContents: "Se requiere iniciar sesión para ver el contenido"

12
locales/index.d.ts vendored
View File

@ -3934,6 +3934,10 @@ export interface Locale extends ILocale {
* *
*/ */
"logoutConfirm": string; "logoutConfirm": string;
/**
*
*/
"logoutWillClearClientData": string;
/** /**
* *
*/ */
@ -9203,6 +9207,10 @@ export interface Locale extends ILocale {
* *
*/ */
"birthdayFollowings": string; "birthdayFollowings": string;
/**
*
*/
"chat": string;
}; };
"_cw": { "_cw": {
/** /**
@ -10226,6 +10234,10 @@ export interface Locale extends ILocale {
* *
*/ */
"roleTimeline": string; "roleTimeline": string;
/**
*
*/
"chat": string;
}; };
}; };
"_dialog": { "_dialog": {

View File

@ -424,6 +424,7 @@ antennaExcludeBots: "Escludere i Bot"
antennaKeywordsDescription: "Sparando con uno spazio indichi la condizione E (and). Separando con un a capo, indichi la condizione O (or)." antennaKeywordsDescription: "Sparando con uno spazio indichi la condizione E (and). Separando con un a capo, indichi la condizione O (or)."
notifyAntenna: "Invia notifiche delle nuove note" notifyAntenna: "Invia notifiche delle nuove note"
withFileAntenna: "Solo note con file in allegato" withFileAntenna: "Solo note con file in allegato"
excludeNotesInSensitiveChannel: "Escludere le Note dai canali espliciti"
enableServiceworker: "Abilita ServiceWorker" enableServiceworker: "Abilita ServiceWorker"
antennaUsersDescription: "Elenca un nome utente per riga" antennaUsersDescription: "Elenca un nome utente per riga"
caseSensitive: "Sensibile alla distinzione tra maiuscole e minuscole" caseSensitive: "Sensibile alla distinzione tra maiuscole e minuscole"
@ -727,7 +728,7 @@ reporterOrigin: "Segnalazione da"
send: "Inviare" send: "Inviare"
openInNewTab: "Apri in una nuova scheda" openInNewTab: "Apri in una nuova scheda"
openInSideView: "Apri in vista laterale" openInSideView: "Apri in vista laterale"
defaultNavigationBehaviour: "Navigazione preimpostata" defaultNavigationBehaviour: "Tipo di navigazione predefinita"
editTheseSettingsMayBreakAccount: "Modificare queste impostazioni può danneggiare il profilo" editTheseSettingsMayBreakAccount: "Modificare queste impostazioni può danneggiare il profilo"
instanceTicker: "Informazioni sull'istanza da cui vengono le note" instanceTicker: "Informazioni sull'istanza da cui vengono le note"
waitingFor: "Aspettando {x}" waitingFor: "Aspettando {x}"
@ -866,7 +867,7 @@ noBotProtectionWarning: "Non è stata impostata alcuna protezione dai Bot"
configure: "Imposta" configure: "Imposta"
postToGallery: "Pubblicare nella galleria" postToGallery: "Pubblicare nella galleria"
postToHashtag: "Pubblica a questo hashtag" postToHashtag: "Pubblica a questo hashtag"
gallery: "Galleria" gallery: "Gallerie"
recentPosts: "Pubblicazioni recenti" recentPosts: "Pubblicazioni recenti"
popularPosts: "Le più visualizzate" popularPosts: "Le più visualizzate"
shareWithNote: "Condividere in nota" shareWithNote: "Condividere in nota"
@ -978,6 +979,7 @@ document: "Documentazione"
numberOfPageCache: "Numero di pagine cache" numberOfPageCache: "Numero di pagine cache"
numberOfPageCacheDescription: "Aumenta l'usabilità, ma aumenta anche il carico e l'utilizzo della memoria." numberOfPageCacheDescription: "Aumenta l'usabilità, ma aumenta anche il carico e l'utilizzo della memoria."
logoutConfirm: "Vuoi davvero uscire da Misskey? " logoutConfirm: "Vuoi davvero uscire da Misskey? "
logoutWillClearClientData: "All'uscita, la configurazione del client viene rimossa dal browser. Per ripristinarla quando si effettua nuovamente l'accesso, abilitare il backup automatico."
lastActiveDate: "Data dell'ultimo utilizzo" lastActiveDate: "Data dell'ultimo utilizzo"
statusbar: "Barra di stato" statusbar: "Barra di stato"
pleaseSelect: "Scegli un'opzione" pleaseSelect: "Scegli un'opzione"
@ -1340,6 +1342,9 @@ right: "Destra"
bottom: "Sotto" bottom: "Sotto"
top: "Sopra" top: "Sopra"
embed: "Incorporare" embed: "Incorporare"
settingsMigrating: "Migrazione delle impostazioni. Attendere prego ... (Puoi anche migrare manualmente in un secondo momento, nel menu: Impostazioni → Altro → Migrazione delle impostazioni)"
readonly: "Sola lettura"
goToDeck: "Torna al Deck"
_chat: _chat:
noMessagesYet: "Ancora nessun messaggio" noMessagesYet: "Ancora nessun messaggio"
newMessage: "Nuovo messaggio" newMessage: "Nuovo messaggio"
@ -1369,6 +1374,7 @@ _chat:
muteThisRoom: "Silenzia stanza" muteThisRoom: "Silenzia stanza"
deleteRoom: "Elimina stanza" deleteRoom: "Elimina stanza"
chatNotAvailableForThisAccountOrServer: "Questo server, o questo profilo ha disabilitato la chat." chatNotAvailableForThisAccountOrServer: "Questo server, o questo profilo ha disabilitato la chat."
chatIsReadOnlyForThisAccountOrServer: "Le chat, su questo server o su questo profilo, sono di sola lettura. Impossibile scrivere in chat o creare e partecipare a stanze."
chatNotAvailableInOtherAccount: "La chat non è disponibile nel profilo dell'altra persona." chatNotAvailableInOtherAccount: "La chat non è disponibile nel profilo dell'altra persona."
cannotChatWithTheUser: "Impossibile chattare con questa persona" cannotChatWithTheUser: "Impossibile chattare con questa persona"
cannotChatWithTheUser_description: "La chat potrebbe non essere disponibile, oppure l'altra persona potrebbe non esserlo." cannotChatWithTheUser_description: "La chat potrebbe non essere disponibile, oppure l'altra persona potrebbe non esserlo."
@ -1929,6 +1935,7 @@ _role:
canImportFollowing: "Può importare Following" canImportFollowing: "Può importare Following"
canImportMuting: "Può importare Silenziati" canImportMuting: "Può importare Silenziati"
canImportUserLists: "Può importare liste di Profili" canImportUserLists: "Può importare liste di Profili"
chatAvailability: "Chat consentita"
_condition: _condition:
roleAssignedTo: "Assegnato a ruoli manualmente" roleAssignedTo: "Assegnato a ruoli manualmente"
isLocal: "Profilo locale" isLocal: "Profilo locale"
@ -2362,6 +2369,7 @@ _widgets:
chooseList: "Seleziona una lista" chooseList: "Seleziona una lista"
clicker: "Cliccheria" clicker: "Cliccheria"
birthdayFollowings: "Compleanni del giorno" birthdayFollowings: "Compleanni del giorno"
chat: "Chat"
_cw: _cw:
hide: "Nascondere" hide: "Nascondere"
show: "Continua la lettura..." show: "Continua la lettura..."
@ -2594,8 +2602,8 @@ _notification:
renote: "Rinota" renote: "Rinota"
_deck: _deck:
alwaysShowMainColumn: "Mostra sempre la colonna principale" alwaysShowMainColumn: "Mostra sempre la colonna principale"
columnAlign: "Allineare colonne" columnAlign: "Allineamento delle colonne"
columnGap: "Margine tra le colonne" columnGap: "Spessore del margine tra colonne"
deckMenuPosition: "Posizione del menu Deck" deckMenuPosition: "Posizione del menu Deck"
navbarPosition: "Posizione barra di navigazione" navbarPosition: "Posizione barra di navigazione"
addColumn: "Aggiungi colonna" addColumn: "Aggiungi colonna"
@ -2624,10 +2632,11 @@ _deck:
tl: "Timeline" tl: "Timeline"
antenna: "Antenne" antenna: "Antenne"
list: "Liste" list: "Liste"
channel: "Canale" channel: "Canali"
mentions: "Menzioni" mentions: "Menzioni"
direct: "Note Dirette" direct: "Note Dirette"
roleTimeline: "Timeline Ruolo" roleTimeline: "Timeline Ruolo"
chat: "Chat"
_dialog: _dialog:
charactersExceeded: "Hai superato il limite di {max} caratteri! ({current})" charactersExceeded: "Hai superato il limite di {max} caratteri! ({current})"
charactersBelow: "Sei al di sotto del minimo di {min} caratteri! ({current})" charactersBelow: "Sei al di sotto del minimo di {min} caratteri! ({current})"

View File

@ -979,6 +979,7 @@ document: "ドキュメント"
numberOfPageCache: "ページキャッシュ数" numberOfPageCache: "ページキャッシュ数"
numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。" numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。"
logoutConfirm: "ログアウトしますか?" logoutConfirm: "ログアウトしますか?"
logoutWillClearClientData: "ログアウトするとクライアントの設定情報がブラウザから消去されます。再ログイン時に設定情報を復元できるようにするためには、設定の自動バックアップを有効にしてください。"
lastActiveDate: "最終利用日時" lastActiveDate: "最終利用日時"
statusbar: "ステータスバー" statusbar: "ステータスバー"
pleaseSelect: "選択してください" pleaseSelect: "選択してください"
@ -2420,6 +2421,7 @@ _widgets:
chooseList: "リストを選択" chooseList: "リストを選択"
clicker: "クリッカー" clicker: "クリッカー"
birthdayFollowings: "今日誕生日のユーザー" birthdayFollowings: "今日誕生日のユーザー"
chat: "チャット"
_cw: _cw:
hide: "隠す" hide: "隠す"
@ -2704,6 +2706,7 @@ _deck:
mentions: "あなた宛て" mentions: "あなた宛て"
direct: "ダイレクト" direct: "ダイレクト"
roleTimeline: "ロールタイムライン" roleTimeline: "ロールタイムライン"
chat: "チャット"
_dialog: _dialog:
charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"

View File

@ -489,7 +489,7 @@ next: "다음"
retype: "다시 입력" retype: "다시 입력"
noteOf: "{user}의 노트" noteOf: "{user}의 노트"
quoteAttached: "인용함" quoteAttached: "인용함"
quoteQuestion: "인용해서 작성하시겠습니까?" quoteQuestion: "인용해서 첨부하시겠습니까?"
attachAsFileQuestion: "붙여넣으려는 글이 너무 깁니다. 텍스트 파일로 첨부하시겠습니까?" attachAsFileQuestion: "붙여넣으려는 글이 너무 깁니다. 텍스트 파일로 첨부하시겠습니까?"
onlyOneFileCanBeAttached: "메시지에 첨부할 수 있는 파일은 하나까지입니다" onlyOneFileCanBeAttached: "메시지에 첨부할 수 있는 파일은 하나까지입니다"
signinRequired: "진행하기 전에 로그인을 해 주세요" signinRequired: "진행하기 전에 로그인을 해 주세요"
@ -571,8 +571,8 @@ objectStorageSetPublicRead: "업로드할 때 'public-read'를 설정하기"
s3ForcePathStyleDesc: "s3ForcePathStyle을 활성화하면, 버킷 이름을 URL의 호스트명이 아닌 경로의 일부로써 취급합니다. 셀프 호스트 Minio와 같은 서비스를 사용할 경우 활성화해야 할 수 있습니다." s3ForcePathStyleDesc: "s3ForcePathStyle을 활성화하면, 버킷 이름을 URL의 호스트명이 아닌 경로의 일부로써 취급합니다. 셀프 호스트 Minio와 같은 서비스를 사용할 경우 활성화해야 할 수 있습니다."
serverLogs: "서버 로그" serverLogs: "서버 로그"
deleteAll: "모두 삭제" deleteAll: "모두 삭제"
showFixedPostForm: "타임라인 상단에 글 작성란을 표시" showFixedPostForm: "타임라인 상단에 글 입력란을 표시"
showFixedPostFormInChannel: "채널 타임라인 상단에 글 작성란을 표시" showFixedPostFormInChannel: "채널 타임라인 상단에 글 입력란을 표시"
withRepliesByDefaultForNewlyFollowed: "팔로우 할 때 기본적으로 답글을 타임라인에 나오게 하기" withRepliesByDefaultForNewlyFollowed: "팔로우 할 때 기본적으로 답글을 타임라인에 나오게 하기"
newNoteRecived: "새 노트가 있습니다" newNoteRecived: "새 노트가 있습니다"
sounds: "소리" sounds: "소리"
@ -720,7 +720,7 @@ abuseReports: "신고"
reportAbuse: "신고" reportAbuse: "신고"
reportAbuseRenote: "리노트 신고하기" reportAbuseRenote: "리노트 신고하기"
reportAbuseOf: "{name} 신고하기" reportAbuseOf: "{name} 신고하기"
fillAbuseReportDescription: "신고하려는 이유를 자세히 알려주세요. 특정 게시물을 신고할 때에는 게시물의 URL도 포함해 주세요." fillAbuseReportDescription: "신고 사유를 자세히 기재해 주세요. 대상 노트나 페이지 등이 있는 경우에는 해당 URL도 기재해 주세요."
abuseReported: "신고를 보냈습니다. 신고해 주셔서 감사합니다." abuseReported: "신고를 보냈습니다. 신고해 주셔서 감사합니다."
reporter: "신고자" reporter: "신고자"
reporteeOrigin: "피신고자" reporteeOrigin: "피신고자"
@ -825,7 +825,7 @@ editCode: "코드 수정"
apply: "적용" apply: "적용"
receiveAnnouncementFromInstance: "이 서버의 알림을 이메일로 수신할게요" receiveAnnouncementFromInstance: "이 서버의 알림을 이메일로 수신할게요"
emailNotification: "메일 알림" emailNotification: "메일 알림"
publish: "게시" publish: "공개"
inChannelSearch: "채널에서 검색" inChannelSearch: "채널에서 검색"
useReactionPickerForContextMenu: "우클릭하여 리액션 선택기 열기" useReactionPickerForContextMenu: "우클릭하여 리액션 선택기 열기"
typingUsers: "{users}님이 입력 중" typingUsers: "{users}님이 입력 중"
@ -1081,7 +1081,7 @@ sensitiveWords: "민감한 단어"
sensitiveWordsDescription: "설정한 단어가 포함된 노트의 공개 범위를 '홈'으로 강제합니다. 개행으로 구분하여 여러 개를 지정할 수 있습니다." sensitiveWordsDescription: "설정한 단어가 포함된 노트의 공개 범위를 '홈'으로 강제합니다. 개행으로 구분하여 여러 개를 지정할 수 있습니다."
sensitiveWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 됩니다." sensitiveWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 됩니다."
prohibitedWords: "금지 단어" prohibitedWords: "금지 단어"
prohibitedWordsDescription: "설정된 단어가 포함되는 노트를 작성하려고 하면, 오류가 발생하도록 합니다. 줄바꿈으로 구분지어 복수 설정할 수 있습니다." prohibitedWordsDescription: "설정된 단어가 포함되는 노트를 게시하려고 하면, 오류가 발생하도록 합니다. 줄바꿈으로 구분지어 복수 설정할 수 있습니다."
prohibitedWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 됩니다." prohibitedWordsDescription2: "공백으로 구분하면 AND 지정이 되며, 키워드를 슬래시로 둘러싸면 정규 표현식이 됩니다."
hiddenTags: "숨긴 해시태그" hiddenTags: "숨긴 해시태그"
hiddenTagsDescription: "설정한 태그를 트렌드에 표시하지 않도록 합니다. 줄 바꿈으로 하나씩 나눠서 설정할 수 있습니다." hiddenTagsDescription: "설정한 태그를 트렌드에 표시하지 않도록 합니다. 줄 바꿈으로 하나씩 나눠서 설정할 수 있습니다."
@ -1373,7 +1373,7 @@ _chat:
muteThisRoom: "이 룸을 뮤트" muteThisRoom: "이 룸을 뮤트"
deleteRoom: "룸을 삭제" deleteRoom: "룸을 삭제"
chatNotAvailableForThisAccountOrServer: "이 서버 또는 이 계정에서 채팅이 활성화되어 있지 않습니다." chatNotAvailableForThisAccountOrServer: "이 서버 또는 이 계정에서 채팅이 활성화되어 있지 않습니다."
chatIsReadOnlyForThisAccountOrServer: "이 서버 또는 이 계정에서 채팅은 읽기 전용입니다. 새로 쓰거나 채팅을 만들거나 참가할 수 없습니다." chatIsReadOnlyForThisAccountOrServer: "이 서버 또는 이 계정에서 채팅은 읽기 전용입니다. 새로 쓰거나 채팅을 만들거나 참가할 수 없습니다."
chatNotAvailableInOtherAccount: "상대방 계정에서 채팅 기능을 사용할 수 없는 상태입니다." chatNotAvailableInOtherAccount: "상대방 계정에서 채팅 기능을 사용할 수 없는 상태입니다."
cannotChatWithTheUser: "이 유저와 채팅을 시작할 수 없습니다" cannotChatWithTheUser: "이 유저와 채팅을 시작할 수 없습니다"
cannotChatWithTheUser_description: "채팅을 사용할 수 없는 상태이거나 상대방이 채팅을 열지 않은 상태입니다." cannotChatWithTheUser_description: "채팅을 사용할 수 없는 상태이거나 상대방이 채팅을 열지 않은 상태입니다."
@ -1542,7 +1542,7 @@ _initialTutorial:
description3: "이 외에도, '리스트 타임라인'이나 '채널 타임라인' 등이 있습니다. 자세한 사항은 {link}에서 확인하실 수 있습니다." description3: "이 외에도, '리스트 타임라인'이나 '채널 타임라인' 등이 있습니다. 자세한 사항은 {link}에서 확인하실 수 있습니다."
_postNote: _postNote:
title: "노트 게시 설정" title: "노트 게시 설정"
description1: "Misskey에 노트를 쓸 때에는 다양한 옵션을 설정할 수 있습니다. 노트를 작성하는 화면은 이렇게 생겼습니다." description1: "Misskey에 노트를 게시할 때에는 다양한 옵션 설정이 가능합니다. 노트를 게시할 때 쓰이는 '글 입력란'은 이렇게 생겼습니다."
_visibility: _visibility:
description: "노트를 볼 수 있는 사람을 제한할 수 있습니다." description: "노트를 볼 수 있는 사람을 제한할 수 있습니다."
public: "모든 유저에게 공개합니다." public: "모든 유저에게 공개합니다."
@ -1562,7 +1562,7 @@ _initialTutorial:
_howToMakeAttachmentsSensitive: _howToMakeAttachmentsSensitive:
title: "첨부 파일을 열람주의로 설정하려면?" title: "첨부 파일을 열람주의로 설정하려면?"
description: "서버의 가이드라인에 따라 필요한 이미지, 또는 그대로 노출되기에 부적절한 미디어는 '열람 주의'를 설정해 주세요." description: "서버의 가이드라인에 따라 필요한 이미지, 또는 그대로 노출되기에 부적절한 미디어는 '열람 주의'를 설정해 주세요."
tryThisFile: "이 작성 창에 첨부된 이미지를 열람 주의로 설정해 보세요!" tryThisFile: "이 입력란에 첨부된 이미지를 열람 주의로 설정해 보세요!"
_exampleNote: _exampleNote:
note: "낫또 뚜껑 뜯다가 실수했다…" note: "낫또 뚜껑 뜯다가 실수했다…"
method: "첨부 파일을 열람 주의로 설정하려면, 해당 파일을 클릭하여 메뉴를 열고, '열람주의로 설정'을 클릭합니다." method: "첨부 파일을 열람 주의로 설정하려면, 해당 파일을 클릭하여 메뉴를 열고, '열람주의로 설정'을 클릭합니다."
@ -1816,7 +1816,7 @@ _achievements:
description: "드라이브 폴더에 스스로를 넣게 했다" description: "드라이브 폴더에 스스로를 넣게 했다"
_reactWithoutRead: _reactWithoutRead:
title: "읽고 답하긴 하시는 건가요?" title: "읽고 답하긴 하시는 건가요?"
description: "100자가 넘는 노트를 작성한 지 3초 안에 리액션했다" description: "100자가 넘는 노트를 게시한 지 3초 안에 리액션했다"
_clickedClickHere: _clickedClickHere:
title: "여길 눌러보세요" title: "여길 눌러보세요"
description: "여기를 눌렀다" description: "여기를 눌렀다"
@ -1845,7 +1845,7 @@ _achievements:
_cookieClicked: _cookieClicked:
title: "쿠키를 클릭하는 게임" title: "쿠키를 클릭하는 게임"
description: "쿠키를 클릭했다" description: "쿠키를 클릭했다"
flavor: "소프트웨어 착각하지 않았어?" flavor: "소프트웨어 착각하지 않으셨나요?"
_brainDiver: _brainDiver:
title: "Brain Diver" title: "Brain Diver"
description: "Brain Diver로의 링크를 첨부했다" description: "Brain Diver로의 링크를 첨부했다"
@ -2368,6 +2368,7 @@ _widgets:
chooseList: "리스트 선택" chooseList: "리스트 선택"
clicker: "클리커" clicker: "클리커"
birthdayFollowings: "오늘이 생일인 유저" birthdayFollowings: "오늘이 생일인 유저"
chat: "채팅"
_cw: _cw:
hide: "숨기기" hide: "숨기기"
show: "더 보기" show: "더 보기"
@ -2634,6 +2635,7 @@ _deck:
mentions: "받은 멘션" mentions: "받은 멘션"
direct: "다이렉트" direct: "다이렉트"
roleTimeline: "역할 타임라인" roleTimeline: "역할 타임라인"
chat: "채팅"
_dialog: _dialog:
charactersExceeded: "최대 글자수를 초과하였습니다! 현재 {current} / 최대 {max}" charactersExceeded: "최대 글자수를 초과하였습니다! 현재 {current} / 최대 {max}"
charactersBelow: "최소 글자수 미만입니다! 현재 {current} / 최소 {min}" charactersBelow: "최소 글자수 미만입니다! 현재 {current} / 최소 {min}"

View File

@ -979,6 +979,7 @@ document: "文档"
numberOfPageCache: "缓存页数" numberOfPageCache: "缓存页数"
numberOfPageCacheDescription: "设置较高的值会更方便用户,但设备的负载和内存使用量会增加。" numberOfPageCacheDescription: "设置较高的值会更方便用户,但设备的负载和内存使用量会增加。"
logoutConfirm: "是否确认登出?" logoutConfirm: "是否确认登出?"
logoutWillClearClientData: "登出时将会从浏览器中删除客户端的设置信息。如果想要在再次登入时恢复设置信息,请在设置里打开自动备份。"
lastActiveDate: "最后活跃时间" lastActiveDate: "最后活跃时间"
statusbar: "状态栏" statusbar: "状态栏"
pleaseSelect: "请选择" pleaseSelect: "请选择"
@ -2367,6 +2368,7 @@ _widgets:
chooseList: "选择列表" chooseList: "选择列表"
clicker: "点击器" clicker: "点击器"
birthdayFollowings: "今天是他们的生日" birthdayFollowings: "今天是他们的生日"
chat: "聊天"
_cw: _cw:
hide: "隐藏" hide: "隐藏"
show: "查看更多" show: "查看更多"
@ -2633,6 +2635,7 @@ _deck:
mentions: "提及" mentions: "提及"
direct: "指定用户" direct: "指定用户"
roleTimeline: "角色时间线" roleTimeline: "角色时间线"
chat: "聊天"
_dialog: _dialog:
charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}" charactersExceeded: "已经超过了最大字符数! 当前字符数 {current} / 限制字符数 {max}"
charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}" charactersBelow: "低于最小字符数!当前字符数 {current} / 限制字符数 {min}"

View File

@ -424,6 +424,7 @@ antennaExcludeBots: "排除機器人帳戶"
antennaKeywordsDescription: "空格代表「以及」AND換行代表「或者」OR" antennaKeywordsDescription: "空格代表「以及」AND換行代表「或者」OR"
notifyAntenna: "通知有新貼文" notifyAntenna: "通知有新貼文"
withFileAntenna: "僅帶有附件的貼文" withFileAntenna: "僅帶有附件的貼文"
excludeNotesInSensitiveChannel: "排除敏感頻道的貼文"
enableServiceworker: "啟用瀏覽器的推播通知" enableServiceworker: "啟用瀏覽器的推播通知"
antennaUsersDescription: "填寫使用者名稱,以換行分隔" antennaUsersDescription: "填寫使用者名稱,以換行分隔"
caseSensitive: "區分大小寫" caseSensitive: "區分大小寫"
@ -978,6 +979,7 @@ document: "文件"
numberOfPageCache: "快取頁面數" numberOfPageCache: "快取頁面數"
numberOfPageCacheDescription: "增加數量會提高便利性,但也會增加負荷與記憶體使用量。" numberOfPageCacheDescription: "增加數量會提高便利性,但也會增加負荷與記憶體使用量。"
logoutConfirm: "確定要登出嗎?" logoutConfirm: "確定要登出嗎?"
logoutWillClearClientData: "當您登出時,客戶端的設定資訊將從瀏覽器中清除。為了能夠在重新登入時恢復您的設定資訊,請啟用設定內的自動備份選項。"
lastActiveDate: "上次使用日期及時間" lastActiveDate: "上次使用日期及時間"
statusbar: "狀態列" statusbar: "狀態列"
pleaseSelect: "請選擇" pleaseSelect: "請選擇"
@ -1307,7 +1309,7 @@ availableRoles: "可用角色"
acknowledgeNotesAndEnable: "了解注意事項後再開啟。" acknowledgeNotesAndEnable: "了解注意事項後再開啟。"
federationSpecified: "此伺服器以白名單聯邦的方式運作。除了管理員指定的伺服器外,它無法與其他伺服器互動。" federationSpecified: "此伺服器以白名單聯邦的方式運作。除了管理員指定的伺服器外,它無法與其他伺服器互動。"
federationDisabled: "此伺服器未開啟站台聯邦。無法與其他伺服器上的使用者互動。" federationDisabled: "此伺服器未開啟站台聯邦。無法與其他伺服器上的使用者互動。"
confirmOnReact: "反應時確認" confirmOnReact: "在做出反應前先確認"
reactAreYouSure: "用「 {emoji} 」反應嗎?" reactAreYouSure: "用「 {emoji} 」反應嗎?"
markAsSensitiveConfirm: "要將這個媒體設定為敏感嗎?" markAsSensitiveConfirm: "要將這個媒體設定為敏感嗎?"
unmarkAsSensitiveConfirm: "要解除這個媒體的敏感設定嗎?" unmarkAsSensitiveConfirm: "要解除這個媒體的敏感設定嗎?"
@ -1444,7 +1446,7 @@ _accountSettings:
makeNotesHiddenBefore: "隱藏過去的貼文" makeNotesHiddenBefore: "隱藏過去的貼文"
makeNotesHiddenBeforeDescription: "啟用此功能後,超過設定的日期和時間或超過設定時間的貼文將僅對自己顯示(私密化)。 如果您再次停用它,貼文的公開狀態也會恢復原狀。" makeNotesHiddenBeforeDescription: "啟用此功能後,超過設定的日期和時間或超過設定時間的貼文將僅對自己顯示(私密化)。 如果您再次停用它,貼文的公開狀態也會恢復原狀。"
mayNotEffectForFederatedNotes: "聯邦發送至遠端伺服器的貼文可能會不受影響。" mayNotEffectForFederatedNotes: "聯邦發送至遠端伺服器的貼文可能會不受影響。"
mayNotEffectSomeSituations: "這些限制已經簡化。它們可能不適用於某些情況,例如在遠端伺服器上檢視或管理時。" mayNotEffectSomeSituations: "這些限制僅是簡化版本。在某些情況下,例如在遠端伺服器上瀏覽或進行審核時,可能不會套用這些限制。"
notesHavePassedSpecifiedPeriod: "早於指定時間的貼文" notesHavePassedSpecifiedPeriod: "早於指定時間的貼文"
notesOlderThanSpecifiedDateAndTime: "指定時間和日期之前的貼文" notesOlderThanSpecifiedDateAndTime: "指定時間和日期之前的貼文"
_abuseUserReport: _abuseUserReport:
@ -2366,6 +2368,7 @@ _widgets:
chooseList: "選擇清單" chooseList: "選擇清單"
clicker: "點擊器" clicker: "點擊器"
birthdayFollowings: "今天生日的使用者" birthdayFollowings: "今天生日的使用者"
chat: "聊天"
_cw: _cw:
hide: "隱藏" hide: "隱藏"
show: "顯示內容" show: "顯示內容"
@ -2632,6 +2635,7 @@ _deck:
mentions: "提及" mentions: "提及"
direct: "指定使用者" direct: "指定使用者"
roleTimeline: "角色時間軸" roleTimeline: "角色時間軸"
chat: "聊天"
_dialog: _dialog:
charactersExceeded: "您的貼文太長了!現時字數 {current}/限制字數 {max}" charactersExceeded: "您的貼文太長了!現時字數 {current}/限制字數 {max}"
charactersBelow: "您的貼文太短了!現時字數 {current}/限制字數 {min}" charactersBelow: "您的貼文太短了!現時字數 {current}/限制字數 {min}"

View File

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2025.4.0", "version": "2025.4.1-alpha.1",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
@ -85,8 +85,7 @@
"@aiscript-dev/aiscript-languageserver": "-" "@aiscript-dev/aiscript-languageserver": "-"
}, },
"patchedDependencies": { "patchedDependencies": {
"re2": "scripts/dependency-patches/re2.patch", "re2": "scripts/dependency-patches/re2.patch"
"vite": "scripts/dependency-patches/vite.patch"
} }
} }
} }

View File

@ -37,17 +37,17 @@
}, },
"optionalDependencies": { "optionalDependencies": {
"@swc/core-android-arm64": "1.3.11", "@swc/core-android-arm64": "1.3.11",
"@swc/core-darwin-arm64": "1.11.11", "@swc/core-darwin-arm64": "1.11.18",
"@swc/core-darwin-x64": "1.11.11", "@swc/core-darwin-x64": "1.11.18",
"@swc/core-freebsd-x64": "1.3.11", "@swc/core-freebsd-x64": "1.3.11",
"@swc/core-linux-arm-gnueabihf": "1.11.11", "@swc/core-linux-arm-gnueabihf": "1.11.18",
"@swc/core-linux-arm64-gnu": "1.11.11", "@swc/core-linux-arm64-gnu": "1.11.18",
"@swc/core-linux-arm64-musl": "1.11.11", "@swc/core-linux-arm64-musl": "1.11.18",
"@swc/core-linux-x64-gnu": "1.11.11", "@swc/core-linux-x64-gnu": "1.11.18",
"@swc/core-linux-x64-musl": "1.11.11", "@swc/core-linux-x64-musl": "1.11.18",
"@swc/core-win32-arm64-msvc": "1.11.11", "@swc/core-win32-arm64-msvc": "1.11.18",
"@swc/core-win32-ia32-msvc": "1.11.11", "@swc/core-win32-ia32-msvc": "1.11.18",
"@swc/core-win32-x64-msvc": "1.11.11", "@swc/core-win32-x64-msvc": "1.11.18",
"@tensorflow/tfjs": "4.22.0", "@tensorflow/tfjs": "4.22.0",
"@tensorflow/tfjs-node": "4.22.0", "@tensorflow/tfjs-node": "4.22.0",
"bufferutil": "4.0.9", "bufferutil": "4.0.9",
@ -67,8 +67,8 @@
"utf-8-validate": "6.0.5" "utf-8-validate": "6.0.5"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.772.0", "@aws-sdk/client-s3": "3.782.0",
"@aws-sdk/lib-storage": "3.772.0", "@aws-sdk/lib-storage": "3.782.0",
"@discordapp/twemoji": "15.1.0", "@discordapp/twemoji": "15.1.0",
"@fastify/accepts": "5.0.2", "@fastify/accepts": "5.0.2",
"@fastify/cookie": "11.0.2", "@fastify/cookie": "11.0.2",
@ -78,12 +78,12 @@
"@fastify/multipart": "9.0.3", "@fastify/multipart": "9.0.3",
"@fastify/static": "8.1.1", "@fastify/static": "8.1.1",
"@fastify/view": "10.0.2", "@fastify/view": "10.0.2",
"@misskey-dev/sharp-read-bmp": "1.2.0", "@misskey-dev/sharp-read-bmp": "1.3.0",
"@misskey-dev/summaly": "5.2.0", "@misskey-dev/summaly": "5.2.0",
"@napi-rs/canvas": "0.1.68", "@napi-rs/canvas": "0.1.69",
"@nestjs/common": "11.0.12", "@nestjs/common": "11.0.16",
"@nestjs/core": "11.0.12", "@nestjs/core": "11.0.15",
"@nestjs/testing": "11.0.12", "@nestjs/testing": "11.0.15",
"@peertube/http-signature": "1.7.0", "@peertube/http-signature": "1.7.0",
"@sentry/node": "8.55.0", "@sentry/node": "8.55.0",
"@sentry/profiling-node": "8.55.0", "@sentry/profiling-node": "8.55.0",
@ -91,7 +91,7 @@
"@sinonjs/fake-timers": "11.3.1", "@sinonjs/fake-timers": "11.3.1",
"@smithy/node-http-handler": "2.5.0", "@smithy/node-http-handler": "2.5.0",
"@swc/cli": "0.6.0", "@swc/cli": "0.6.0",
"@swc/core": "1.11.11", "@swc/core": "1.11.18",
"@twemoji/parser": "15.1.1", "@twemoji/parser": "15.1.1",
"accepts": "1.3.8", "accepts": "1.3.8",
"ajv": "8.17.1", "ajv": "8.17.1",
@ -100,7 +100,7 @@
"bcryptjs": "2.4.3", "bcryptjs": "2.4.3",
"blurhash": "2.0.5", "blurhash": "2.0.5",
"body-parser": "1.20.3", "body-parser": "1.20.3",
"bullmq": "5.44.1", "bullmq": "5.48.1",
"cacheable-lookup": "7.0.0", "cacheable-lookup": "7.0.0",
"cbor": "9.0.2", "cbor": "9.0.2",
"chalk": "5.4.1", "chalk": "5.4.1",
@ -111,13 +111,13 @@
"content-disposition": "0.5.4", "content-disposition": "0.5.4",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"deep-email-validator": "0.1.21", "deep-email-validator": "0.1.21",
"fastify": "5.2.1", "fastify": "5.2.2",
"fastify-raw-body": "5.0.0", "fastify-raw-body": "5.0.0",
"feed": "4.2.2", "feed": "4.2.2",
"file-type": "19.6.0", "file-type": "19.6.0",
"fluent-ffmpeg": "2.1.3", "fluent-ffmpeg": "2.1.3",
"form-data": "4.0.2", "form-data": "4.0.2",
"got": "14.4.6", "got": "14.4.7",
"happy-dom": "16.8.1", "happy-dom": "16.8.1",
"hpagent": "1.2.0", "hpagent": "1.2.0",
"htmlescape": "1.1.1", "htmlescape": "1.1.1",
@ -149,7 +149,7 @@
"oauth2orize": "1.12.0", "oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2", "oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14", "os-utils": "0.0.14",
"otpauth": "9.3.6", "otpauth": "9.4.0",
"parse5": "7.2.1", "parse5": "7.2.1",
"pg": "8.14.1", "pg": "8.14.1",
"pkce-challenge": "4.1.0", "pkce-challenge": "4.1.0",
@ -167,17 +167,17 @@
"rxjs": "7.8.2", "rxjs": "7.8.2",
"sanitize-html": "2.15.0", "sanitize-html": "2.15.0",
"secure-json-parse": "3.0.2", "secure-json-parse": "3.0.2",
"sharp": "0.33.5", "sharp": "0.34.1",
"slacc": "0.0.10", "slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",
"systeminformation": "5.25.11", "systeminformation": "5.25.11",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tmp": "0.2.3", "tmp": "0.2.3",
"tsc-alias": "1.8.11", "tsc-alias": "1.8.15",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typeorm": "0.3.21", "typeorm": "0.3.22",
"typescript": "5.8.2", "typescript": "5.8.3",
"ulid": "2.4.0", "ulid": "2.4.0",
"vary": "1.1.2", "vary": "1.1.2",
"web-push": "3.6.7", "web-push": "3.6.7",
@ -187,7 +187,7 @@
"devDependencies": { "devDependencies": {
"@jest/globals": "29.7.0", "@jest/globals": "29.7.0",
"@nestjs/platform-express": "10.4.15", "@nestjs/platform-express": "10.4.15",
"@sentry/vue": "9.8.0", "@sentry/vue": "9.12.0",
"@simplewebauthn/types": "12.0.0", "@simplewebauthn/types": "12.0.0",
"@swc/jest": "0.2.37", "@swc/jest": "0.2.37",
"@types/accepts": "1.3.7", "@types/accepts": "1.3.7",
@ -206,7 +206,7 @@
"@types/jsrsasign": "10.5.15", "@types/jsrsasign": "10.5.15",
"@types/mime-types": "2.1.4", "@types/mime-types": "2.1.4",
"@types/ms": "0.7.34", "@types/ms": "0.7.34",
"@types/node": "22.13.10", "@types/node": "22.14.0",
"@types/nodemailer": "6.4.17", "@types/nodemailer": "6.4.17",
"@types/oauth": "0.9.6", "@types/oauth": "0.9.6",
"@types/oauth2orize": "1.11.5", "@types/oauth2orize": "1.11.5",
@ -217,17 +217,17 @@
"@types/random-seed": "0.3.5", "@types/random-seed": "0.3.5",
"@types/ratelimiter": "3.4.6", "@types/ratelimiter": "3.4.6",
"@types/rename": "1.0.7", "@types/rename": "1.0.7",
"@types/sanitize-html": "2.13.0", "@types/sanitize-html": "2.15.0",
"@types/semver": "7.5.8", "@types/semver": "7.7.0",
"@types/simple-oauth2": "5.0.7", "@types/simple-oauth2": "5.0.7",
"@types/sinonjs__fake-timers": "8.1.5", "@types/sinonjs__fake-timers": "8.1.5",
"@types/tinycolor2": "1.4.6", "@types/tinycolor2": "1.4.6",
"@types/tmp": "0.2.6", "@types/tmp": "0.2.6",
"@types/vary": "1.1.3", "@types/vary": "1.1.3",
"@types/web-push": "3.6.4", "@types/web-push": "3.6.4",
"@types/ws": "8.18.0", "@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.27.0", "@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.27.0", "@typescript-eslint/parser": "8.29.1",
"aws-sdk-client-mock": "4.1.0", "aws-sdk-client-mock": "4.1.0",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.31.0",

View File

@ -25,6 +25,7 @@ import InstanceChart from '@/core/chart/charts/instance.js';
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
import { SystemAccountService } from '@/core/SystemAccountService.js'; import { SystemAccountService } from '@/core/SystemAccountService.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { AntennaService } from '@/core/AntennaService.js';
@Injectable() @Injectable()
export class AccountMoveService { export class AccountMoveService {
@ -63,6 +64,7 @@ export class AccountMoveService {
private queueService: QueueService, private queueService: QueueService,
private systemAccountService: SystemAccountService, private systemAccountService: SystemAccountService,
private roleService: RoleService, private roleService: RoleService,
private antennaService: AntennaService,
) { ) {
} }
@ -123,6 +125,7 @@ export class AccountMoveService {
this.copyMutings(src, dst), this.copyMutings(src, dst),
this.copyRoles(src, dst), this.copyRoles(src, dst),
this.updateLists(src, dst), this.updateLists(src, dst),
this.antennaService.onMoveAccount(src, dst),
]); ]);
} catch { } catch {
/* skip if any error happens */ /* skip if any error happens */

View File

@ -5,18 +5,20 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import { In } from 'typeorm';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import * as Acct from '@/misc/acct.js';
import type { Packed } from '@/misc/json-schema.js';
import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js';
import type { MiAntenna } from '@/models/Antenna.js'; import type { MiAntenna } from '@/models/Antenna.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { CacheService } from './CacheService.js';
import * as Acct from '@/misc/acct.js';
import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js';
import type { AntennasRepository, UserListMembershipsRepository } from '@/models/_.js';
import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import type { OnApplicationShutdown } from '@nestjs/common'; import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable() @Injectable()
@ -37,6 +39,7 @@ export class AntennaService implements OnApplicationShutdown {
@Inject(DI.userListMembershipsRepository) @Inject(DI.userListMembershipsRepository)
private userListMembershipsRepository: UserListMembershipsRepository, private userListMembershipsRepository: UserListMembershipsRepository,
private cacheService: CacheService,
private utilityService: UtilityService, private utilityService: UtilityService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private fanoutTimelineService: FanoutTimelineService, private fanoutTimelineService: FanoutTimelineService,
@ -111,9 +114,6 @@ export class AntennaService implements OnApplicationShutdown {
@bindThis @bindThis
public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<boolean> { public async checkHitAntenna(antenna: MiAntenna, note: (MiNote | Packed<'Note'>), noteUser: { id: MiUser['id']; username: string; host: string | null; isBot: boolean; }): Promise<boolean> {
if (note.visibility === 'specified') return false;
if (note.visibility === 'followers') return false;
if (antenna.excludeNotesInSensitiveChannel && note.channel?.isSensitive) return false; if (antenna.excludeNotesInSensitiveChannel && note.channel?.isSensitive) return false;
if (antenna.excludeBots && noteUser.isBot) return false; if (antenna.excludeBots && noteUser.isBot) return false;
@ -122,6 +122,18 @@ export class AntennaService implements OnApplicationShutdown {
if (!antenna.withReplies && note.replyId != null) return false; if (!antenna.withReplies && note.replyId != null) return false;
if (note.visibility === 'specified') {
if (note.userId !== antenna.userId) {
if (note.visibleUserIds == null) return false;
if (!note.visibleUserIds.includes(antenna.userId)) return false;
}
}
if (note.visibility === 'followers') {
const isFollowing = Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(antenna.userId), note.userId);
if (!isFollowing && antenna.userId !== note.userId) return false;
}
if (antenna.src === 'home') { if (antenna.src === 'home') {
// TODO // TODO
} else if (antenna.src === 'list') { } else if (antenna.src === 'list') {
@ -208,6 +220,41 @@ export class AntennaService implements OnApplicationShutdown {
return this.antennas; return this.antennas;
} }
@bindThis
public async onMoveAccount(src: MiUser, dst: MiUser): Promise<void> {
// There is a possibility for users to add the srcUser to their antennas, but it's low, so we don't check it.
// Get MiAntenna[] from cache and filter to select antennas with the src user is in the users list
const srcUserAcct = this.utilityService.getFullApAccount(src.username, src.host).toLowerCase();
const antennasToMigrate = (await this.getAntennas()).filter(antenna => {
return antenna.users.some(user => {
const { username, host } = Acct.parse(user);
return this.utilityService.getFullApAccount(username, host).toLowerCase() === srcUserAcct;
});
});
if (antennasToMigrate.length === 0) return;
const antennaIds = antennasToMigrate.map(x => x.id);
// Update the antennas by appending dst users acct to the users list
const dstUserAcct = '@' + Acct.toString({ username: dst.username, host: dst.host });
await this.antennasRepository.createQueryBuilder('antenna')
.update()
.set({
users: () => 'array_append(antenna.users, :dstUserAcct)',
})
.where('antenna.id IN (:...antennaIds)', { antennaIds })
.setParameters({ dstUserAcct })
.execute();
// announce update to event
for (const newAntenna of await this.antennasRepository.findBy({ id: In(antennaIds) })) {
this.globalEventService.publishInternalEvent('antennaUpdated', newAntenna);
}
}
@bindThis @bindThis
public dispose(): void { public dispose(): void {
this.redisForSub.off('message', this.onRedisMessage); this.redisForSub.off('message', this.onRedisMessage);

View File

@ -232,7 +232,7 @@ export class ChatService {
const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted, toUser); const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted, toUser);
this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo); this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo);
//this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo); this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo);
}, 3000); }, 3000);
} }
@ -302,7 +302,7 @@ export class ChatService {
if (marker == null) continue; if (marker == null) continue;
this.globalEventService.publishMainStream(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo); this.globalEventService.publishMainStream(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo);
//this.pushNotificationService.pushNotification(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo); this.pushNotificationService.pushNotification(membershipsOtherThanMe[i].userId, 'newChatMessage', packedMessageForTo);
} }
}, 3000); }, 3000);

View File

@ -54,7 +54,7 @@ export class FanoutTimelineEndpointService {
} }
@bindThis @bindThis
private async getMiNotes(ps: TimelineOptions): Promise<MiNote[]> { async getMiNotes(ps: TimelineOptions): Promise<MiNote[]> {
// 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える // 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える
if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]); if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]);

View File

@ -6,7 +6,7 @@
import { URL } from 'node:url'; import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as parse5 from 'parse5'; import * as parse5 from 'parse5';
import { Window, XMLSerializer } from 'happy-dom'; import { type Document, type HTMLParagraphElement, Window, XMLSerializer } from 'happy-dom';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { intersperse } from '@/misc/prelude/array.js'; import { intersperse } from '@/misc/prelude/array.js';
@ -23,6 +23,8 @@ type ChildNode = DefaultTreeAdapterMap['childNode'];
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
export type Appender = (document: Document, body: HTMLParagraphElement) => void;
@Injectable() @Injectable()
export class MfmService { export class MfmService {
constructor( constructor(
@ -267,7 +269,7 @@ export class MfmService {
} }
@bindThis @bindThis
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) { public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = []) {
if (nodes == null) { if (nodes == null) {
return null; return null;
} }
@ -492,6 +494,10 @@ export class MfmService {
appendChildren(nodes, body); appendChildren(nodes, body);
for (const additionalAppender of additionalAppenders) {
additionalAppender(doc, body);
}
// Remove the unnecessary namespace // Remove the unnecessary namespace
const serialized = new XMLSerializer().serializeToString(body).replace(/^\s*<p xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">/, '<p>'); const serialized = new XMLSerializer().serializeToString(body).replace(/^\s*<p xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">/, '<p>');

View File

@ -22,6 +22,7 @@ type PushNotificationsTypes = {
note: Packed<'Note'>; note: Packed<'Note'>;
}; };
'readAllNotifications': undefined; 'readAllNotifications': undefined;
newChatMessage: Packed<'ChatMessage'>;
}; };
// Reduce length because push message servers have character limits // Reduce length because push message servers have character limits

View File

@ -38,6 +38,18 @@ import type {
import type httpSignature from '@peertube/http-signature'; import type httpSignature from '@peertube/http-signature';
import type * as Bull from 'bullmq'; import type * as Bull from 'bullmq';
export const QUEUE_TYPES = [
'system',
'endedPollNotification',
'deliver',
'inbox',
'db',
'relationship',
'objectStorage',
'userWebhookDeliver',
'systemWebhookDeliver',
] as const;
@Injectable() @Injectable()
export class QueueService { export class QueueService {
constructor( constructor(
@ -529,15 +541,35 @@ export class QueueService {
} }
@bindThis @bindThis
public destroy() { private getQueue(type: typeof QUEUE_TYPES[number]) {
this.deliverQueue.once('cleaned', (jobs, status) => { switch (type) {
//deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); case 'system': return this.systemQueue;
}); case 'endedPollNotification': return this.endedPollNotificationQueue;
this.deliverQueue.clean(0, 0, 'delayed'); case 'deliver': return this.deliverQueue;
case 'inbox': return this.inboxQueue;
case 'db': return this.dbQueue;
case 'relationship': return this.relationshipQueue;
case 'objectStorage': return this.objectStorageQueue;
case 'userWebhookDeliver': return this.userWebhookDeliverQueue;
case 'systemWebhookDeliver': return this.systemWebhookDeliverQueue;
default: throw new Error(`Unrecognized queue type: ${type}`);
}
}
this.inboxQueue.once('cleaned', (jobs, status) => { @bindThis
//inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); public clearQueue(queueType: typeof QUEUE_TYPES[number], state: '*' | 'completed' | 'wait' | 'active' | 'paused' | 'prioritized' | 'delayed' | 'failed') {
}); const queue = this.getQueue(queueType);
this.inboxQueue.clean(0, 0, 'delayed');
if (state === '*') {
queue.clean(0, 0, 'completed');
queue.clean(0, 0, 'wait');
queue.clean(0, 0, 'active');
queue.clean(0, 0, 'paused');
queue.clean(0, 0, 'prioritized');
queue.clean(0, 0, 'delayed');
queue.clean(0, 0, 'failed');
} else {
queue.clean(0, 0, state);
}
} }
} }

View File

@ -5,11 +5,14 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { OnApplicationShutdown } from '@nestjs/common';
import { DataSource, IsNull } from 'typeorm'; import { DataSource, IsNull } from 'typeorm';
import * as Redis from 'ioredis';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { MiLocalUser, MiUser } from '@/models/User.js'; import { MiLocalUser, MiUser } from '@/models/User.js';
import { MiSystemAccount, MiUsedUsername, MiUserKeypair, MiUserProfile, type UsersRepository, type SystemAccountsRepository } from '@/models/_.js'; import { MiSystemAccount, MiUsedUsername, MiUserKeypair, MiUserProfile, type UsersRepository, type SystemAccountsRepository } from '@/models/_.js';
import type { MiMeta, UserProfilesRepository } from '@/models/_.js'; import type { MiMeta, UserProfilesRepository } from '@/models/_.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { MemoryKVCache } from '@/misc/cache.js'; import { MemoryKVCache } from '@/misc/cache.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@ -20,10 +23,13 @@ import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
export const SYSTEM_ACCOUNT_TYPES = ['actor', 'relay', 'proxy'] as const; export const SYSTEM_ACCOUNT_TYPES = ['actor', 'relay', 'proxy'] as const;
@Injectable() @Injectable()
export class SystemAccountService { export class SystemAccountService implements OnApplicationShutdown {
private cache: MemoryKVCache<MiLocalUser>; private cache: MemoryKVCache<MiLocalUser>;
constructor( constructor(
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.db) @Inject(DI.db)
private db: DataSource, private db: DataSource,
@ -42,6 +48,31 @@ export class SystemAccountService {
private idService: IdService, private idService: IdService,
) { ) {
this.cache = new MemoryKVCache<MiLocalUser>(1000 * 60 * 10); // 10m this.cache = new MemoryKVCache<MiLocalUser>(1000 * 60 * 10); // 10m
this.redisForSub.on('message', this.onMessage);
}
@bindThis
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'metaUpdated': {
if (body.before != null && body.before.name !== body.after.name) {
for (const account of SYSTEM_ACCOUNT_TYPES) {
await this.updateCorrespondingUserProfile(account, {
name: body.after.name,
});
}
}
break;
}
default:
break;
}
}
} }
@bindThis @bindThis
@ -145,7 +176,7 @@ export class SystemAccountService {
@bindThis @bindThis
public async updateCorrespondingUserProfile(type: typeof SYSTEM_ACCOUNT_TYPES[number], extra: { public async updateCorrespondingUserProfile(type: typeof SYSTEM_ACCOUNT_TYPES[number], extra: {
name?: string; name?: string | null;
description?: MiUserProfile['description']; description?: MiUserProfile['description'];
}): Promise<MiLocalUser> { }): Promise<MiLocalUser> {
const user = await this.fetch(type); const user = await this.fetch(type);
@ -169,4 +200,15 @@ export class SystemAccountService {
return updated; return updated;
} }
@bindThis
public dispose(): void {
this.redisForSub.off('message', this.onMessage);
this.cache.dispose();
}
@bindThis
public onApplicationShutdown(signal?: string): void {
this.dispose();
}
} }

View File

@ -411,8 +411,8 @@ export class WebhookTestService {
name: user.name, name: user.name,
username: user.username, username: user.username,
host: user.host, host: user.host,
avatarUrl: user.avatarUrl, avatarUrl: user.avatarId == null ? null : user.avatarUrl,
avatarBlurhash: user.avatarBlurhash, avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash,
avatarDecorations: user.avatarDecorations.map(it => ({ avatarDecorations: user.avatarDecorations.map(it => ({
id: it.id, id: it.id,
angle: it.angle, angle: it.angle,
@ -441,8 +441,8 @@ export class WebhookTestService {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: user.updatedAt?.toISOString() ?? null, updatedAt: user.updatedAt?.toISOString() ?? null,
lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null, lastFetchedAt: user.lastFetchedAt?.toISOString() ?? null,
bannerUrl: user.bannerUrl, bannerUrl: user.bannerId == null ? null : user.bannerUrl,
bannerBlurhash: user.bannerBlurhash, bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash,
isLocked: user.isLocked, isLocked: user.isLocked,
isSilenced: false, isSilenced: false,
isSuspended: user.isSuspended, isSuspended: user.isSuspended,

View File

@ -5,7 +5,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import { MfmService } from '@/core/MfmService.js'; import { MfmService, Appender } from '@/core/MfmService.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { extractApHashtagObjects } from './models/tag.js'; import { extractApHashtagObjects } from './models/tag.js';
@ -25,17 +25,17 @@ export class ApMfmService {
} }
@bindThis @bindThis
public getNoteHtml(note: Pick<MiNote, 'text' | 'mentionedRemoteUsers'>, apAppend?: string) { public getNoteHtml(note: Pick<MiNote, 'text' | 'mentionedRemoteUsers'>, additionalAppender: Appender[] = []) {
let noMisskeyContent = false; let noMisskeyContent = false;
const srcMfm = (note.text ?? '') + (apAppend ?? ''); const srcMfm = (note.text ?? '');
const parsed = mfm.parse(srcMfm); const parsed = mfm.parse(srcMfm);
if (!apAppend && parsed?.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) { if (!additionalAppender.length && parsed.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) {
noMisskeyContent = true; noMisskeyContent = true;
} }
const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers)); const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers), additionalAppender);
return { return {
content, content,

View File

@ -19,7 +19,7 @@ import type { MiEmoji } from '@/models/Emoji.js';
import type { MiPoll } from '@/models/Poll.js'; import type { MiPoll } from '@/models/Poll.js';
import type { MiPollVote } from '@/models/PollVote.js'; import type { MiPollVote } from '@/models/PollVote.js';
import { UserKeypairService } from '@/core/UserKeypairService.js'; import { UserKeypairService } from '@/core/UserKeypairService.js';
import { MfmService } from '@/core/MfmService.js'; import { MfmService, type Appender } from '@/core/MfmService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js'; import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { MiUserKeypair } from '@/models/UserKeypair.js'; import type { MiUserKeypair } from '@/models/UserKeypair.js';
@ -430,10 +430,24 @@ export class ApRendererService {
poll = await this.pollsRepository.findOneBy({ noteId: note.id }); poll = await this.pollsRepository.findOneBy({ noteId: note.id });
} }
let apAppend = ''; const apAppend: Appender[] = [];
if (quote) { if (quote) {
apAppend += `\n\nRE: ${quote}`; // Append quote link as `<br><br><span class="quote-inline">RE: <a href="...">...</a></span>`
// the claas name `quote-inline` is used in non-misskey clients for styling quote notes.
// For compatibility, the span part should be kept as possible.
apAppend.push((doc, body) => {
body.appendChild(doc.createElement('br'));
body.appendChild(doc.createElement('br'));
const span = doc.createElement('span');
span.className = 'quote-inline';
span.appendChild(doc.createTextNode('RE: '));
const link = doc.createElement('a');
link.setAttribute('href', quote);
link.textContent = quote;
span.appendChild(link);
body.appendChild(span);
});
} }
const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw; const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;

View File

@ -486,8 +486,8 @@ export class UserEntityService implements OnModuleInit {
name: user.name, name: user.name,
username: user.username, username: user.username,
host: user.host, host: user.host,
avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user), avatarUrl: (user.avatarId == null ? null : user.avatarUrl) ?? this.getIdenticonUrl(user),
avatarBlurhash: user.avatarBlurhash, avatarBlurhash: (user.avatarId == null ? null : user.avatarBlurhash),
avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({ avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({
id: ud.id, id: ud.id,
angle: ud.angle || undefined, angle: ud.angle || undefined,
@ -533,8 +533,8 @@ export class UserEntityService implements OnModuleInit {
createdAt: this.idService.parse(user.id).date.toISOString(), createdAt: this.idService.parse(user.id).date.toISOString(),
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,
bannerUrl: user.bannerUrl, bannerUrl: user.bannerId == null ? null : user.bannerUrl,
bannerBlurhash: user.bannerBlurhash, bannerBlurhash: user.bannerId == null ? null : user.bannerBlurhash,
isLocked: user.isLocked, isLocked: user.isLocked,
isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),
isSuspended: user.isSuspended, isSuspended: user.isSuspended,

View File

@ -118,21 +118,25 @@ export class MiUser {
@JoinColumn() @JoinColumn()
public banner: MiDriveFile | null; public banner: MiDriveFile | null;
// avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること
@Column('varchar', { @Column('varchar', {
length: 512, nullable: true, length: 512, nullable: true,
}) })
public avatarUrl: string | null; public avatarUrl: string | null;
// bannerId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは bannerId の non-null チェックをすること
@Column('varchar', { @Column('varchar', {
length: 512, nullable: true, length: 512, nullable: true,
}) })
public bannerUrl: string | null; public bannerUrl: string | null;
// avatarId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは avatarId の non-null チェックをすること
@Column('varchar', { @Column('varchar', {
length: 128, nullable: true, length: 128, nullable: true,
}) })
public avatarBlurhash: string | null; public avatarBlurhash: string | null;
// bannerId が null になったとしてもこれが null でない可能性があるため、このフィールドを使うときは bannerId の non-null チェックをすること
@Column('varchar', { @Column('varchar', {
length: 128, nullable: true, length: 128, nullable: true,
}) })

View File

@ -3,39 +3,58 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm'; import {
FindOneOptions,
InsertQueryBuilder,
ObjectLiteral,
QueryRunner,
Repository,
SelectQueryBuilder,
} from 'typeorm';
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js'; import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js';
import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js'; import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js';
import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; import {
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; RawSqlResultsToEntityTransformer,
} from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js';
import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import { MiAccessToken } from '@/models/AccessToken.js'; import { MiAccessToken } from '@/models/AccessToken.js';
import { MiAd } from '@/models/Ad.js'; import { MiAd } from '@/models/Ad.js';
import { MiAnnouncement } from '@/models/Announcement.js'; import { MiAnnouncement } from '@/models/Announcement.js';
import { MiAnnouncementRead } from '@/models/AnnouncementRead.js'; import { MiAnnouncementRead } from '@/models/AnnouncementRead.js';
import { MiAntenna } from '@/models/Antenna.js'; import { MiAntenna } from '@/models/Antenna.js';
import { MiApp } from '@/models/App.js'; import { MiApp } from '@/models/App.js';
import { MiAvatarDecoration } from '@/models/AvatarDecoration.js';
import { MiAuthSession } from '@/models/AuthSession.js'; import { MiAuthSession } from '@/models/AuthSession.js';
import { MiAvatarDecoration } from '@/models/AvatarDecoration.js';
import { MiBlocking } from '@/models/Blocking.js'; import { MiBlocking } from '@/models/Blocking.js';
import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiChannel } from '@/models/Channel.js';
import { MiChannelFavorite } from '@/models/ChannelFavorite.js'; import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
import { MiChatApproval } from '@/models/ChatApproval.js';
import { MiChatMessage } from '@/models/ChatMessage.js';
import { MiChatRoom } from '@/models/ChatRoom.js';
import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
import { MiClip } from '@/models/Clip.js'; import { MiClip } from '@/models/Clip.js';
import { MiClipNote } from '@/models/ClipNote.js';
import { MiClipFavorite } from '@/models/ClipFavorite.js'; import { MiClipFavorite } from '@/models/ClipFavorite.js';
import { MiClipNote } from '@/models/ClipNote.js';
import { MiDriveFile } from '@/models/DriveFile.js'; import { MiDriveFile } from '@/models/DriveFile.js';
import { MiDriveFolder } from '@/models/DriveFolder.js'; import { MiDriveFolder } from '@/models/DriveFolder.js';
import { MiEmoji } from '@/models/Emoji.js'; import { MiEmoji } from '@/models/Emoji.js';
import { MiFlash } from '@/models/Flash.js';
import { MiFlashLike } from '@/models/FlashLike.js';
import { MiFollowing } from '@/models/Following.js'; import { MiFollowing } from '@/models/Following.js';
import { MiFollowRequest } from '@/models/FollowRequest.js'; import { MiFollowRequest } from '@/models/FollowRequest.js';
import { MiGalleryLike } from '@/models/GalleryLike.js'; import { MiGalleryLike } from '@/models/GalleryLike.js';
import { MiGalleryPost } from '@/models/GalleryPost.js'; import { MiGalleryPost } from '@/models/GalleryPost.js';
import { MiHashtag } from '@/models/Hashtag.js'; import { MiHashtag } from '@/models/Hashtag.js';
import { MiInstance } from '@/models/Instance.js'; import { MiInstance } from '@/models/Instance.js';
import { MiMahjongGame } from '@/models/MahjongGame.js';
import { MiMeta } from '@/models/Meta.js'; import { MiMeta } from '@/models/Meta.js';
import { MiModerationLog } from '@/models/ModerationLog.js'; import { MiModerationLog } from '@/models/ModerationLog.js';
import { MiMuting } from '@/models/Muting.js'; import { MiMuting } from '@/models/Muting.js';
import { MiRenoteMuting } from '@/models/RenoteMuting.js';
import { MiNote } from '@/models/Note.js'; import { MiNote } from '@/models/Note.js';
import { MiNoteFavorite } from '@/models/NoteFavorite.js'; import { MiNoteFavorite } from '@/models/NoteFavorite.js';
import { MiNoteReaction } from '@/models/NoteReaction.js'; import { MiNoteReaction } from '@/models/NoteReaction.js';
@ -50,43 +69,38 @@ import { MiPromoRead } from '@/models/PromoRead.js';
import { MiRegistrationTicket } from '@/models/RegistrationTicket.js'; import { MiRegistrationTicket } from '@/models/RegistrationTicket.js';
import { MiRegistryItem } from '@/models/RegistryItem.js'; import { MiRegistryItem } from '@/models/RegistryItem.js';
import { MiRelay } from '@/models/Relay.js'; import { MiRelay } from '@/models/Relay.js';
import { MiRenoteMuting } from '@/models/RenoteMuting.js';
import { MiRetentionAggregation } from '@/models/RetentionAggregation.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
import { MiRole } from '@/models/Role.js';
import { MiRoleAssignment } from '@/models/RoleAssignment.js';
import { MiSignin } from '@/models/Signin.js'; import { MiSignin } from '@/models/Signin.js';
import { MiSwSubscription } from '@/models/SwSubscription.js'; import { MiSwSubscription } from '@/models/SwSubscription.js';
import { MiSystemAccount } from '@/models/SystemAccount.js'; import { MiSystemAccount } from '@/models/SystemAccount.js';
import { MiSystemWebhook } from '@/models/SystemWebhook.js';
import { MiUsedUsername } from '@/models/UsedUsername.js'; import { MiUsedUsername } from '@/models/UsedUsername.js';
import { MiUser } from '@/models/User.js'; import { MiUser } from '@/models/User.js';
import { MiUserIp } from '@/models/UserIp.js'; import { MiUserIp } from '@/models/UserIp.js';
import { MiUserKeypair } from '@/models/UserKeypair.js'; import { MiUserKeypair } from '@/models/UserKeypair.js';
import { MiUserList } from '@/models/UserList.js'; import { MiUserList } from '@/models/UserList.js';
import { MiUserListFavorite } from '@/models/UserListFavorite.js';
import { MiUserListMembership } from '@/models/UserListMembership.js'; import { MiUserListMembership } from '@/models/UserListMembership.js';
import { MiUserMemo } from '@/models/UserMemo.js';
import { MiUserNotePining } from '@/models/UserNotePining.js'; import { MiUserNotePining } from '@/models/UserNotePining.js';
import { MiUserPending } from '@/models/UserPending.js'; import { MiUserPending } from '@/models/UserPending.js';
import { MiUserProfile } from '@/models/UserProfile.js'; import { MiUserProfile } from '@/models/UserProfile.js';
import { MiUserPublickey } from '@/models/UserPublickey.js'; import { MiUserPublickey } from '@/models/UserPublickey.js';
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js'; import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
import { MiUserMemo } from '@/models/UserMemo.js';
import { MiWebhook } from '@/models/Webhook.js'; import { MiWebhook } from '@/models/Webhook.js';
import { MiSystemWebhook } from '@/models/SystemWebhook.js';
import { MiChannel } from '@/models/Channel.js';
import { MiRetentionAggregation } from '@/models/RetentionAggregation.js';
import { MiRole } from '@/models/Role.js';
import { MiRoleAssignment } from '@/models/RoleAssignment.js';
import { MiFlash } from '@/models/Flash.js';
import { MiFlashLike } from '@/models/FlashLike.js';
import { MiUserListFavorite } from '@/models/UserListFavorite.js';
import { MiChatMessage } from '@/models/ChatMessage.js';
import { MiChatRoom } from '@/models/ChatRoom.js';
import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js';
import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
import { MiChatApproval } from '@/models/ChatApproval.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
import { MiMahjongGame } from '@/models/MahjongGame.js';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
export interface MiRepository<T extends ObjectLiteral> { export interface MiRepository<T extends ObjectLiteral> {
createTableColumnNames(this: Repository<T> & MiRepository<T>): string[]; createTableColumnNames(this: Repository<T> & MiRepository<T>): string[];
insertOne(this: Repository<T> & MiRepository<T>, entity: QueryDeepPartialEntity<T>, findOptions?: Pick<FindOneOptions<T>, 'relations'>): Promise<T>; insertOne(this: Repository<T> & MiRepository<T>, entity: QueryDeepPartialEntity<T>, findOptions?: Pick<FindOneOptions<T>, 'relations'>): Promise<T>;
insertOneImpl(this: Repository<T> & MiRepository<T>, entity: QueryDeepPartialEntity<T>, findOptions?: Pick<FindOneOptions<T>, 'relations'>, queryRunner?: QueryRunner): Promise<T>;
selectAliasColumnNames(this: Repository<T> & MiRepository<T>, queryBuilder: InsertQueryBuilder<T>, builder: SelectQueryBuilder<T>): void; selectAliasColumnNames(this: Repository<T> & MiRepository<T>, queryBuilder: InsertQueryBuilder<T>, builder: SelectQueryBuilder<T>): void;
} }
@ -95,6 +109,21 @@ export const miRepository = {
return this.metadata.columns.filter(column => column.isSelect && !column.isVirtual).map(column => column.databaseName); return this.metadata.columns.filter(column => column.isSelect && !column.isVirtual).map(column => column.databaseName);
}, },
async insertOne(entity, findOptions?) { async insertOne(entity, findOptions?) {
const opt = this.manager.connection.options as PostgresConnectionOptions;
if (opt.replication) {
const queryRunner = this.manager.connection.createQueryRunner('master');
try {
return this.insertOneImpl(entity, findOptions, queryRunner);
} finally {
await queryRunner.release();
}
} else {
return this.insertOneImpl(entity, findOptions);
}
},
async insertOneImpl(entity, findOptions?, queryRunner?) {
// ---- insert + returningの結果を共通テーブル式(CTE)に保持するクエリを生成 ----
const queryBuilder = this.createQueryBuilder().insert().values(entity); const queryBuilder = this.createQueryBuilder().insert().values(entity);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const mainAlias = queryBuilder.expressionMap.mainAlias!; const mainAlias = queryBuilder.expressionMap.mainAlias!;
@ -102,7 +131,9 @@ export const miRepository = {
mainAlias.name = 't'; mainAlias.name = 't';
const columnNames = this.createTableColumnNames(); const columnNames = this.createTableColumnNames();
queryBuilder.returning(columnNames.reduce((a, c) => `${a}, ${queryBuilder.escape(c)}`, '').slice(2)); queryBuilder.returning(columnNames.reduce((a, c) => `${a}, ${queryBuilder.escape(c)}`, '').slice(2));
const builder = this.createQueryBuilder().addCommonTableExpression(queryBuilder, 'cte', { columnNames });
// ---- 共通テーブル式(CTE)から結果を取得 ----
const builder = this.createQueryBuilder(undefined, queryRunner).addCommonTableExpression(queryBuilder, 'cte', { columnNames });
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
builder.expressionMap.mainAlias!.tablePath = 'cte'; builder.expressionMap.mainAlias!.tablePath = 'cte';
this.selectAliasColumnNames(queryBuilder, builder); this.selectAliasColumnNames(queryBuilder, builder);
@ -206,7 +237,9 @@ export {
}; };
export type AbuseUserReportsRepository = Repository<MiAbuseUserReport> & MiRepository<MiAbuseUserReport>; export type AbuseUserReportsRepository = Repository<MiAbuseUserReport> & MiRepository<MiAbuseUserReport>;
export type AbuseReportNotificationRecipientRepository = Repository<MiAbuseReportNotificationRecipient> & MiRepository<MiAbuseReportNotificationRecipient>; export type AbuseReportNotificationRecipientRepository =
Repository<MiAbuseReportNotificationRecipient>
& MiRepository<MiAbuseReportNotificationRecipient>;
export type AccessTokensRepository = Repository<MiAccessToken> & MiRepository<MiAccessToken>; export type AccessTokensRepository = Repository<MiAccessToken> & MiRepository<MiAccessToken>;
export type AdsRepository = Repository<MiAd> & MiRepository<MiAd>; export type AdsRepository = Repository<MiAd> & MiRepository<MiAd>;
export type AnnouncementsRepository = Repository<MiAnnouncement> & MiRepository<MiAnnouncement>; export type AnnouncementsRepository = Repository<MiAnnouncement> & MiRepository<MiAnnouncement>;

View File

@ -5,7 +5,7 @@
// https://github.com/typeorm/typeorm/issues/2400 // https://github.com/typeorm/typeorm/issues/2400
import pg from 'pg'; import pg from 'pg';
import { DataSource, Logger } from 'typeorm'; import { DataSource, Logger, type QueryRunner } from 'typeorm';
import * as highlight from 'cli-highlight'; import * as highlight from 'cli-highlight';
import { entities as charts } from '@/core/chart/entities.js'; import { entities as charts } from '@/core/chart/entities.js';
import { Config } from '@/config.js'; import { Config } from '@/config.js';
@ -97,6 +97,7 @@ const sqlLogger = dbLogger.createSubLogger('sql', 'gray');
export type LoggerProps = { export type LoggerProps = {
disableQueryTruncation?: boolean; disableQueryTruncation?: boolean;
enableQueryParamLogging?: boolean; enableQueryParamLogging?: boolean;
printReplicationMode?: boolean,
}; };
function highlightSql(sql: string) { function highlightSql(sql: string) {
@ -122,8 +123,10 @@ class MyCustomLogger implements Logger {
} }
@bindThis @bindThis
private transformQueryLog(sql: string) { private transformQueryLog(sql: string, opts?: {
let modded = sql; prefix?: string;
}) {
let modded = opts?.prefix ? opts.prefix + sql : sql;
if (!this.props.disableQueryTruncation) { if (!this.props.disableQueryTruncation) {
modded = truncateSql(modded); modded = truncateSql(modded);
} }
@ -141,18 +144,27 @@ class MyCustomLogger implements Logger {
} }
@bindThis @bindThis
public logQuery(query: string, parameters?: any[]) { public logQuery(query: string, parameters?: any[], queryRunner?: QueryRunner) {
sqlLogger.info(this.transformQueryLog(query), this.transformParameters(parameters)); const prefix = (this.props.printReplicationMode && queryRunner)
? `[${queryRunner.getReplicationMode()}] `
: undefined;
sqlLogger.info(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters));
} }
@bindThis @bindThis
public logQueryError(error: string, query: string, parameters?: any[]) { public logQueryError(error: string, query: string, parameters?: any[], queryRunner?: QueryRunner) {
sqlLogger.error(this.transformQueryLog(query), this.transformParameters(parameters)); const prefix = (this.props.printReplicationMode && queryRunner)
? `[${queryRunner.getReplicationMode()}] `
: undefined;
sqlLogger.error(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters));
} }
@bindThis @bindThis
public logQuerySlow(time: number, query: string, parameters?: any[]) { public logQuerySlow(time: number, query: string, parameters?: any[], queryRunner?: QueryRunner) {
sqlLogger.warn(this.transformQueryLog(query), this.transformParameters(parameters)); const prefix = (this.props.printReplicationMode && queryRunner)
? `[${queryRunner.getReplicationMode()}] `
: undefined;
sqlLogger.warn(this.transformQueryLog(query, { prefix }), this.transformParameters(parameters));
} }
@bindThis @bindThis
@ -300,6 +312,7 @@ export function createPostgresDataSource(config: Config) {
? new MyCustomLogger({ ? new MyCustomLogger({
disableQueryTruncation: config.logging?.sql?.disableQueryTruncation, disableQueryTruncation: config.logging?.sql?.disableQueryTruncation,
enableQueryParamLogging: config.logging?.sql?.enableQueryParamLogging, enableQueryParamLogging: config.logging?.sql?.enableQueryParamLogging,
printReplicationMode: !!config.dbReplications,
}) })
: undefined, : undefined,
maxQueryExecutionTime: 300, maxQueryExecutionTime: 300,

View File

@ -32,6 +32,7 @@ import { isQuote, isRenote } from '@/misc/is-renote.js';
import * as Acct from '@/misc/acct.js'; import * as Acct from '@/misc/acct.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify'; import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify';
import type { FindOptionsWhere } from 'typeorm'; import type { FindOptionsWhere } from 'typeorm';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
const ACTIVITY_JSON = 'application/activity+json; charset=utf-8'; const ACTIVITY_JSON = 'application/activity+json; charset=utf-8';
const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; const LD_JSON = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8';
@ -75,6 +76,7 @@ export class ActivityPubServerService {
private queueService: QueueService, private queueService: QueueService,
private userKeypairService: UserKeypairService, private userKeypairService: UserKeypairService,
private queryService: QueryService, private queryService: QueryService,
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
) { ) {
//this.createServer = this.createServer.bind(this); //this.createServer = this.createServer.bind(this);
} }
@ -461,16 +463,28 @@ export class ActivityPubServerService {
const partOf = `${this.config.url}/users/${userId}/outbox`; const partOf = `${this.config.url}/users/${userId}/outbox`;
if (page) { if (page) {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId) const notes = this.meta.enableFanoutTimeline ? await this.fanoutTimelineEndpointService.getMiNotes({
.andWhere('note.userId = :userId', { userId: user.id }) sinceId: sinceId ?? null,
.andWhere(new Brackets(qb => { untilId: untilId ?? null,
qb limit: limit,
.where('note.visibility = \'public\'') allowPartial: false, // Possibly true? IDK it's OK for ordered collection.
.orWhere('note.visibility = \'home\''); me: null,
})) redisTimelines: [
.andWhere('note.localOnly = FALSE'); `userTimeline:${user.id}`,
`userTimelineWithReplies:${user.id}`,
const notes = await query.limit(limit).getMany(); ],
useDbFallback: true,
ignoreAuthorFromMute: true,
excludePureRenotes: false,
noteFilter: (note) => {
if (note.visibility !== 'home' && note.visibility !== 'public') return false;
if (note.localOnly) return false;
return true;
},
dbFallback: async (untilId, sinceId, limit) => {
return await this.getUserNotesFromDb(sinceId, untilId, limit, user.id);
},
}) : await this.getUserNotesFromDb(sinceId ?? null, untilId ?? null, limit, user.id);
if (sinceId) notes.reverse(); if (sinceId) notes.reverse();
@ -508,6 +522,20 @@ export class ActivityPubServerService {
} }
} }
@bindThis
private async getUserNotesFromDb(untilId: string | null, sinceId: string | null, limit: number, userId: MiUser['id']) {
return await this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), sinceId, untilId)
.andWhere('note.userId = :userId', { userId })
.andWhere(new Brackets(qb => {
qb
.where('note.visibility = \'public\'')
.orWhere('note.visibility = \'home\'');
}))
.andWhere('note.localOnly = FALSE')
.limit(limit)
.getMany();
}
@bindThis @bindThis
private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null) { private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null) {
if (this.meta.federation === 'none') { if (this.meta.federation === 'none') {
@ -735,7 +763,7 @@ export class ActivityPubServerService {
const acct = Acct.parse(request.params.acct); const acct = Acct.parse(request.params.acct);
const user = await this.usersRepository.findOneBy({ const user = await this.usersRepository.findOneBy({
usernameLower: acct.username, usernameLower: acct.username.toLowerCase(),
host: acct.host ?? IsNull(), host: acct.host ?? IsNull(),
isSuspended: false, isSuspended: false,
}); });

View File

@ -221,7 +221,7 @@ export class ServerService implements OnApplicationShutdown {
reply.header('Cache-Control', 'public, max-age=86400'); reply.header('Cache-Control', 'public, max-age=86400');
if (user) { if (user) {
reply.redirect(user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user)); reply.redirect((user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user));
} else { } else {
reply.redirect('/static-assets/user-unknown.png'); reply.redirect('/static-assets/user-unknown.png');
} }

View File

@ -138,7 +138,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
const fromAcct = (acct: Acct.Acct): FindOptionsWhere<MiUser> | number => const fromAcct = (acct: Acct.Acct): FindOptionsWhere<MiUser> | number =>
!acct.host || acct.host === this.config.host.toLowerCase() ? { !acct.host || acct.host === this.config.host.toLowerCase() ? {
usernameLower: acct.username, usernameLower: acct.username.toLowerCase(),
host: IsNull(), host: IsNull(),
isSuspended: false, isSuspended: false,
} : 422; } : 422;

View File

@ -6,7 +6,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
import { QueueService } from '@/core/QueueService.js'; import { QUEUE_TYPES, QueueService } from '@/core/QueueService.js';
export const meta = { export const meta = {
tags: ['admin'], tags: ['admin'],
@ -18,8 +18,11 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: {}, properties: {
required: [], type: { type: 'string', enum: QUEUE_TYPES },
state: { type: 'string', enum: ['*', 'wait', 'delayed'] },
},
required: ['type', 'state'],
} as const; } as const;
@Injectable() @Injectable()
@ -29,7 +32,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queueService: QueueService, private queueService: QueueService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
this.queueService.destroy(); this.queueService.clearQueue(ps.type, ps.state);
this.moderationLogService.log(me, 'clearQueue'); this.moderationLogService.log(me, 'clearQueue');
}); });

View File

@ -534,7 +534,7 @@ export class ClientServerService {
return await reply.view('user', { return await reply.view('user', {
user, profile, me, user, profile, me,
avatarUrl: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), avatarUrl: _user.avatarUrl,
sub: request.params.sub, sub: request.params.sub,
...await this.generateCommonPugData(this.meta), ...await this.generateCommonPugData(this.meta),
clientCtx: htmlSafeJsonStringify({ clientCtx: htmlSafeJsonStringify({

View File

@ -65,7 +65,7 @@ export class FeedService {
generator: 'Misskey', generator: 'Misskey',
description: `${user.notesCount} Notes, ${profile.followingVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.followersVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, description: `${user.notesCount} Notes, ${profile.followingVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.followersVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`,
link: author.link, link: author.link,
image: user.avatarUrl ?? this.userEntityService.getIdenticonUrl(user), image: (user.avatarId == null ? null : user.avatarUrl) ?? this.userEntityService.getIdenticonUrl(user),
feedLinks: { feedLinks: {
json: `${author.link}.json`, json: `${author.link}.json`,
atom: `${author.link}.atom`, atom: `${author.link}.atom`,

View File

@ -381,7 +381,8 @@ describe('User', () => {
await alice.client.request('i/delete-account', { password: alice.password }); await alice.client.request('i/delete-account', { password: alice.password });
// NOTE: user deletion query is slow // NOTE: user deletion query is slow
await sleep(4000); // FIXME: ensure user is removed successfully
await sleep(10000);
const following = await bob.client.request('users/following', { userId: bob.id }); const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 0); // no following relation strictEqual(following.length, 0); // no following relation
@ -480,7 +481,8 @@ describe('User', () => {
await aAdmin.client.request('admin/suspend-user', { userId: alice.id }); await aAdmin.client.request('admin/suspend-user', { userId: alice.id });
// NOTE: user deletion query is slow // NOTE: user deletion query is slow
await sleep(4000); // FIXME: ensure user is removed successfully
await sleep(10000);
const following = await bob.client.request('users/following', { userId: bob.id }); const following = await bob.client.request('users/following', { userId: bob.id });
strictEqual(following.length, 0); // no following relation strictEqual(following.length, 0); // no following relation

View File

@ -6,7 +6,6 @@
process.env.NODE_ENV = 'test'; process.env.NODE_ENV = 'test';
import * as assert from 'assert'; import * as assert from 'assert';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
import { import {
api, api,
failedApiCall, failedApiCall,
@ -19,6 +18,7 @@ import {
userList, userList,
} from '../utils.js'; } from '../utils.js';
import type * as misskey from 'misskey-js'; import type * as misskey from 'misskey-js';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
const compareBy = <T extends { id: string }>(selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => { const compareBy = <T extends { id: string }>(selector: (s: T) => string = (s: T): string => s.id) => (a: T, b: T): number => {
return selector(a).localeCompare(selector(b)); return selector(a).localeCompare(selector(b));
@ -235,12 +235,12 @@ describe('アンテナ', () => {
await failedApiCall({ await failedApiCall({
endpoint: 'antennas/create', endpoint: 'antennas/create',
parameters: { ...defaultParam, keywords: [[]], excludeKeywords: [[]] }, parameters: { ...defaultParam, keywords: [[]], excludeKeywords: [[]] },
user: alice user: alice,
}, { }, {
status: 400, status: 400,
code: 'EMPTY_KEYWORD', code: 'EMPTY_KEYWORD',
id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a' id: '53ee222e-1ddd-4f9a-92e5-9fb82ddb463a',
}) });
}); });
//#endregion //#endregion
//#region 更新(antennas/update) //#region 更新(antennas/update)
@ -274,12 +274,12 @@ describe('アンテナ', () => {
await failedApiCall({ await failedApiCall({
endpoint: 'antennas/update', endpoint: 'antennas/update',
parameters: { ...defaultParam, antennaId: antenna.id, keywords: [[]], excludeKeywords: [[]] }, parameters: { ...defaultParam, antennaId: antenna.id, keywords: [[]], excludeKeywords: [[]] },
user: alice user: alice,
}, { }, {
status: 400, status: 400,
code: 'EMPTY_KEYWORD', code: 'EMPTY_KEYWORD',
id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4' id: '721aaff6-4e1b-4d88-8de6-877fae9f68c4',
}) });
}); });
//#endregion //#endregion
@ -375,14 +375,23 @@ describe('アンテナ', () => {
], ],
}, },
{ {
// https://github.com/misskey-dev/misskey/issues/9025 label: 'フォロワー限定投稿とDM投稿を含む',
label: 'ただし、フォロワー限定投稿とDM投稿を含まない。フォロワーであっても。',
parameters: () => ({}), parameters: () => ({}),
posts: [ posts: [
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'public' }), included: true }, { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'public' }), included: true },
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'home' }), included: true }, { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'home' }), included: true },
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }) }, { note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'followers' }), included: true },
{ note: (): Promise<Note> => post(userFollowedByAlice, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }) }, { note: (): Promise<Note> => post(bob, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [alice.id] }), included: true },
],
},
{
label: 'フォロワー限定投稿とDM投稿を含まない',
parameters: () => ({}),
posts: [
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, visibility: 'public' }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, visibility: 'home' }), included: true },
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, visibility: 'followers' }) },
{ note: (): Promise<Note> => post(bob, { text: `${keyword}`, visibility: 'specified', visibleUserIds: [carol.id] }) },
], ],
}, },
{ {

View File

@ -44,7 +44,7 @@ describe('AnnouncementService', () => {
return usersRepository.insert({ return usersRepository.insert({
id: genAidx(Date.now()), id: genAidx(Date.now()),
username: un, username: un,
usernameLower: un, usernameLower: un.toLowerCase(),
...data, ...data,
}) })
.then(x => usersRepository.findOneByOrFail(x.identifiers[0])); .then(x => usersRepository.findOneByOrFail(x.identifiers[0]));

View File

@ -115,7 +115,7 @@ describe('SigninWithPasskeyApiService', () => {
jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication').mockImplementation(FakeWebauthnVerify); jest.spyOn(webAuthnService, 'verifySignInWithPasskeyAuthentication').mockImplementation(FakeWebauthnVerify);
const dummyUser = { const dummyUser = {
id: uid, username: uid, usernameLower: uid.toLocaleLowerCase(), uri: null, host: null, id: uid, username: uid, usernameLower: uid.toLowerCase(), uri: null, host: null,
}; };
const dummyProfile = { const dummyProfile = {
userId: uid, userId: uid,

View File

@ -74,7 +74,7 @@ describe('UserEntityService', () => {
...userData, ...userData,
id: genAidx(Date.now()), id: genAidx(Date.now()),
username: un, username: un,
usernameLower: un, usernameLower: un.toLowerCase(),
}) })
.then(x => usersRepository.findOneByOrFail(x.identifiers[0])); .then(x => usersRepository.findOneByOrFail(x.identifiers[0]));

View File

@ -21,34 +21,34 @@
"astring": "1.9.0", "astring": "1.9.0",
"buraha": "0.0.1", "buraha": "0.0.1",
"estree-walker": "3.0.3", "estree-walker": "3.0.3",
"frontend-shared": "workspace:*",
"json5": "2.2.3",
"mfm-js": "0.24.0", "mfm-js": "0.24.0",
"misskey-js": "workspace:*", "misskey-js": "workspace:*",
"frontend-shared": "workspace:*",
"punycode.js": "2.3.1", "punycode.js": "2.3.1",
"rollup": "4.36.0", "rollup": "4.39.0",
"sass": "1.86.0", "sass": "1.86.3",
"shiki": "3.2.1", "shiki": "3.2.2",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tsc-alias": "1.8.11", "tsc-alias": "1.8.15",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "5.8.2", "typescript": "5.8.3",
"uuid": "11.1.0", "uuid": "11.1.0",
"json5": "2.2.3", "vite": "6.3.1",
"vite": "6.2.4",
"vue": "3.5.13" "vue": "3.5.13"
}, },
"devDependencies": { "devDependencies": {
"@misskey-dev/summaly": "5.2.0", "@misskey-dev/summaly": "5.2.0",
"@testing-library/vue": "8.1.0", "@testing-library/vue": "8.1.0",
"@types/estree": "1.0.6", "@types/estree": "1.0.7",
"@types/micromatch": "4.0.9", "@types/micromatch": "4.0.9",
"@types/node": "22.13.11", "@types/node": "22.14.0",
"@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/tinycolor2": "1.4.6", "@types/tinycolor2": "1.4.6",
"@types/ws": "8.18.0", "@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.27.0", "@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.27.0", "@typescript-eslint/parser": "8.29.1",
"@vitest/coverage-v8": "3.0.9", "@vitest/coverage-v8": "3.1.1",
"@vue/runtime-core": "3.5.13", "@vue/runtime-core": "3.5.13",
"acorn": "8.14.1", "acorn": "8.14.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
@ -64,7 +64,7 @@
"start-server-and-test": "2.0.11", "start-server-and-test": "2.0.11",
"vite-plugin-turbosnap": "1.0.3", "vite-plugin-turbosnap": "1.0.3",
"vue-component-type-helpers": "2.2.8", "vue-component-type-helpers": "2.2.8",
"vue-eslint-parser": "10.1.1", "vue-eslint-parser": "10.1.3",
"vue-tsc": "2.2.8" "vue-tsc": "2.2.8"
} }
} }

View File

@ -21,14 +21,14 @@
"lint": "pnpm typecheck && pnpm eslint" "lint": "pnpm typecheck && pnpm eslint"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "22.13.11", "@types/node": "22.14.0",
"@typescript-eslint/eslint-plugin": "8.27.0", "@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.27.0", "@typescript-eslint/parser": "8.29.1",
"esbuild": "0.25.1", "esbuild": "0.25.2",
"eslint-plugin-vue": "10.0.0", "eslint-plugin-vue": "10.0.0",
"nodemon": "3.1.9", "nodemon": "3.1.9",
"typescript": "5.8.2", "typescript": "5.8.3",
"vue-eslint-parser": "10.1.1" "vue-eslint-parser": "10.1.3"
}, },
"files": [ "files": [
"js-built" "js-built"

View File

@ -33,6 +33,8 @@
navFg: '@fg', navFg: '@fg',
navActive: '@accent', navActive: '@accent',
navIndicator: '@indicator', navIndicator: '@indicator',
pageHeaderBg: '@bg',
pageHeaderFg: '@fg',
link: '#44a4c1', link: '#44a4c1',
hashtag: '#ff9156', hashtag: '#ff9156',
mention: '@accent', mention: '@accent',

View File

@ -33,6 +33,8 @@
navFg: '@fg', navFg: '@fg',
navActive: '@accent', navActive: '@accent',
navIndicator: '@indicator', navIndicator: '@indicator',
pageHeaderBg: '@bg',
pageHeaderFg: '@fg',
link: '#44a4c1', link: '#44a4c1',
hashtag: '#ff9156', hashtag: '#ff9156',
mention: '@accent', mention: '@accent',

View File

@ -43,6 +43,41 @@ export function channel(id = 'somechannelid', name = 'Some Channel', bannerUrl:
}; };
} }
export function chatMessage(room = false, id = 'somechatmessageid', text = 'Hello!'): entities.ChatMessage {
const fromUser = userLite();
const toRoom = chatRoom();
const toUser = userLite('touserid');
return {
id,
createdAt: '2016-12-28T22:49:51.000Z',
fromUserId: fromUser.id,
fromUser,
text,
isRead: false,
reactions: [],
...room ? {
toRoomId: toRoom.id,
toRoom,
} : {
toUserId: toUser.id,
toUser,
},
};
}
export function chatRoom(id = 'somechatroomid', name = 'Some Chat Room'): entities.ChatRoom {
const owner = userLite('someownerid');
return {
id,
createdAt: '2016-12-28T22:49:51.000Z',
ownerId: owner.id,
owner,
name,
description: 'A chat room for testing',
isMuted: false,
};
}
export function clip(id = 'someclipid', name = 'Some Clip'): entities.Clip { export function clip(id = 'someclipid', name = 'Some Clip'): entities.Clip {
return { return {
id, id,

View File

@ -24,7 +24,7 @@
"@rollup/plugin-json": "6.1.0", "@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "6.0.2", "@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4", "@rollup/pluginutils": "5.1.4",
"@sentry/vue": "9.8.0", "@sentry/vue": "9.12.0",
"@syuilo/aiscript": "0.19.0", "@syuilo/aiscript": "0.19.0",
"@tabler/icons-webfont": "3.31.0", "@tabler/icons-webfont": "3.31.0",
"@twemoji/parser": "15.1.1", "@twemoji/parser": "15.1.1",
@ -33,7 +33,7 @@
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
"analytics": "0.8.16", "analytics": "0.8.16",
"astring": "1.9.0", "astring": "1.9.0",
"broadcast-channel": "7.0.0", "broadcast-channel": "7.1.0",
"buraha": "0.0.1", "buraha": "0.0.1",
"canvas-confetti": "1.9.3", "canvas-confetti": "1.9.3",
"chart.js": "4.4.8", "chart.js": "4.4.8",
@ -41,7 +41,7 @@
"chartjs-chart-matrix": "2.1.1", "chartjs-chart-matrix": "2.1.1",
"chartjs-plugin-gradient": "0.6.1", "chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.2.0", "chartjs-plugin-zoom": "2.2.0",
"chromatic": "11.27.0", "chromatic": "11.28.0",
"compare-versions": "6.1.1", "compare-versions": "6.1.1",
"cropperjs": "2.0.0", "cropperjs": "2.0.0",
"date-fns": "4.1.0", "date-fns": "4.1.0",
@ -61,65 +61,65 @@
"misskey-reversi": "workspace:*", "misskey-reversi": "workspace:*",
"photoswipe": "5.4.4", "photoswipe": "5.4.4",
"punycode.js": "2.3.1", "punycode.js": "2.3.1",
"rollup": "4.36.0", "rollup": "4.39.0",
"sanitize-html": "2.15.0", "sanitize-html": "2.15.0",
"sass": "1.86.0", "sass": "1.86.3",
"shiki": "3.2.1", "shiki": "3.2.2",
"strict-event-emitter-types": "2.0.0", "strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0", "textarea-caret": "3.1.0",
"three": "0.174.0", "three": "0.175.0",
"throttle-debounce": "5.0.2", "throttle-debounce": "5.0.2",
"tinycolor2": "1.6.0", "tinycolor2": "1.6.0",
"tsc-alias": "1.8.11", "tsc-alias": "1.8.15",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "5.8.2", "typescript": "5.8.3",
"uuid": "11.1.0", "uuid": "11.1.0",
"v-code-diff": "1.13.1", "v-code-diff": "1.13.1",
"vite": "6.2.4", "vite": "6.3.1",
"vue": "3.5.13", "vue": "3.5.13",
"vuedraggable": "next", "vuedraggable": "next",
"wanakana": "5.3.1" "wanakana": "5.3.1"
}, },
"devDependencies": { "devDependencies": {
"@misskey-dev/summaly": "5.2.0", "@misskey-dev/summaly": "5.2.0",
"@storybook/addon-actions": "8.6.7", "@storybook/addon-actions": "8.6.12",
"@storybook/addon-essentials": "8.6.7", "@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.7", "@storybook/addon-interactions": "8.6.12",
"@storybook/addon-links": "8.6.7", "@storybook/addon-links": "8.6.12",
"@storybook/addon-mdx-gfm": "8.6.7", "@storybook/addon-mdx-gfm": "8.6.12",
"@storybook/addon-storysource": "8.6.7", "@storybook/addon-storysource": "8.6.12",
"@storybook/blocks": "8.6.7", "@storybook/blocks": "8.6.12",
"@storybook/components": "8.6.7", "@storybook/components": "8.6.12",
"@storybook/core-events": "8.6.7", "@storybook/core-events": "8.6.12",
"@storybook/manager-api": "8.6.7", "@storybook/manager-api": "8.6.12",
"@storybook/preview-api": "8.6.7", "@storybook/preview-api": "8.6.12",
"@storybook/react": "8.6.7", "@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.7", "@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.7", "@storybook/test": "8.6.12",
"@storybook/theming": "8.6.7", "@storybook/theming": "8.6.12",
"@storybook/types": "8.6.7", "@storybook/types": "8.6.12",
"@storybook/vue3": "8.6.7", "@storybook/vue3": "8.6.12",
"@storybook/vue3-vite": "8.6.7", "@storybook/vue3-vite": "8.6.12",
"@testing-library/vue": "8.1.0", "@testing-library/vue": "8.1.0",
"@types/canvas-confetti": "1.9.0", "@types/canvas-confetti": "1.9.0",
"@types/estree": "1.0.6", "@types/estree": "1.0.7",
"@types/matter-js": "0.19.8", "@types/matter-js": "0.19.8",
"@types/micromatch": "4.0.9", "@types/micromatch": "4.0.9",
"@types/node": "22.13.11", "@types/node": "22.14.0",
"@types/punycode.js": "npm:@types/punycode@2.1.4", "@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/sanitize-html": "2.13.0", "@types/sanitize-html": "2.15.0",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@types/throttle-debounce": "5.0.2", "@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6", "@types/tinycolor2": "1.4.6",
"@types/ws": "8.18.0", "@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.27.0", "@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.27.0", "@typescript-eslint/parser": "8.29.1",
"@vitest/coverage-v8": "3.0.9", "@vitest/coverage-v8": "3.1.1",
"@vue/compiler-core": "3.5.13", "@vue/compiler-core": "3.5.13",
"@vue/runtime-core": "3.5.13", "@vue/runtime-core": "3.5.13",
"acorn": "8.14.1", "acorn": "8.14.1",
"cross-env": "7.0.3", "cross-env": "7.0.3",
"cypress": "14.2.0", "cypress": "14.3.0",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.31.0",
"eslint-plugin-vue": "10.0.0", "eslint-plugin-vue": "10.0.0",
"fast-glob": "3.3.3", "fast-glob": "3.3.3",
@ -131,18 +131,17 @@
"msw-storybook-addon": "2.0.4", "msw-storybook-addon": "2.0.4",
"nodemon": "3.1.9", "nodemon": "3.1.9",
"prettier": "3.5.3", "prettier": "3.5.3",
"react": "19.0.0", "react": "19.1.0",
"react-dom": "19.0.0", "react-dom": "19.1.0",
"seedrandom": "3.0.5", "seedrandom": "3.0.5",
"start-server-and-test": "2.0.11", "start-server-and-test": "2.0.11",
"storybook": "8.6.7", "storybook": "8.6.12",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme", "storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"vite-node": "3.0.9",
"vite-plugin-turbosnap": "1.0.3", "vite-plugin-turbosnap": "1.0.3",
"vitest": "3.0.9", "vitest": "3.1.1",
"vitest-fetch-mock": "0.4.5", "vitest-fetch-mock": "0.4.5",
"vue-component-type-helpers": "2.2.8", "vue-component-type-helpers": "2.2.8",
"vue-eslint-parser": "10.1.1", "vue-eslint-parser": "10.1.3",
"vue-tsc": "2.2.8" "vue-tsc": "2.2.8"
} }
} }

View File

@ -21,14 +21,19 @@ type AccountWithToken = Misskey.entities.MeDetailed & { token: string };
export async function getAccounts(): Promise<{ export async function getAccounts(): Promise<{
host: string; host: string;
user: Misskey.entities.User; id: Misskey.entities.User['id'];
username: Misskey.entities.User['username'];
user?: Misskey.entities.User | null;
token: string | null; token: string | null;
}[]> { }[]> {
const tokens = store.s.accountTokens; const tokens = store.s.accountTokens;
const accountInfos = store.s.accountInfos;
const accounts = prefer.s.accounts; const accounts = prefer.s.accounts;
return accounts.map(([host, user]) => ({ return accounts.map(([host, user]) => ({
host, host,
user, id: user.id,
username: user.username,
user: accountInfos[host + '/' + user.id],
token: tokens[host + '/' + user.id] ?? null, token: tokens[host + '/' + user.id] ?? null,
})); }));
} }
@ -36,7 +41,8 @@ export async function getAccounts(): Promise<{
async function addAccount(host: string, user: Misskey.entities.User, token: AccountWithToken['token']) { async function addAccount(host: string, user: Misskey.entities.User, token: AccountWithToken['token']) {
if (!prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) { if (!prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) {
store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + user.id]: token }); store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + user.id]: token });
prefer.commit('accounts', [...prefer.s.accounts, [host, user]]); store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + user.id]: user });
prefer.commit('accounts', [...prefer.s.accounts, [host, { id: user.id, username: user.username }]]);
} }
} }
@ -44,6 +50,10 @@ export async function removeAccount(host: string, id: AccountWithToken['id']) {
const tokens = JSON.parse(JSON.stringify(store.s.accountTokens)); const tokens = JSON.parse(JSON.stringify(store.s.accountTokens));
delete tokens[host + '/' + id]; delete tokens[host + '/' + id];
store.set('accountTokens', tokens); store.set('accountTokens', tokens);
const accountInfos = JSON.parse(JSON.stringify(store.s.accountInfos));
delete accountInfos[host + '/' + id];
store.set('accountInfos', accountInfos);
prefer.commit('accounts', prefer.s.accounts.filter(x => x[0] !== host || x[1].id !== id)); prefer.commit('accounts', prefer.s.accounts.filter(x => x[0] !== host || x[1].id !== id));
} }
@ -121,14 +131,7 @@ export function updateCurrentAccount(accountData: Misskey.entities.MeDetailed) {
for (const [key, value] of Object.entries(accountData)) { for (const [key, value] of Object.entries(accountData)) {
$i[key] = value; $i[key] = value;
} }
prefer.commit('accounts', prefer.s.accounts.map(([host, user]) => { store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + $i.id]: $i });
// TODO: $iのホストも比較したいけど通常null
if (user.id === $i.id) {
return [host, $i];
} else {
return [host, user];
}
}));
$i.token = token; $i.token = token;
miLocalStorage.setItem('account', JSON.stringify($i)); miLocalStorage.setItem('account', JSON.stringify($i));
} }
@ -138,17 +141,9 @@ export function updateCurrentAccountPartial(accountData: Partial<Misskey.entitie
for (const [key, value] of Object.entries(accountData)) { for (const [key, value] of Object.entries(accountData)) {
$i[key] = value; $i[key] = value;
} }
prefer.commit('accounts', prefer.s.accounts.map(([host, user]) => {
// TODO: $iのホストも比較したいけど通常null store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + $i.id]: $i });
if (user.id === $i.id) {
const newUser = JSON.parse(JSON.stringify($i));
for (const [key, value] of Object.entries(accountData)) {
newUser[key] = value;
}
return [host, newUser];
}
return [host, user];
}));
miLocalStorage.setItem('account', JSON.stringify($i)); miLocalStorage.setItem('account', JSON.stringify($i));
} }
@ -223,25 +218,42 @@ export async function openAccountMenu(opts: {
}, ev: MouseEvent) { }, ev: MouseEvent) {
if (!$i) return; if (!$i) return;
function createItem(host: string, account: Misskey.entities.User): MenuItem { function createItem(host: string, id: Misskey.entities.User['id'], username: Misskey.entities.User['username'], account: Misskey.entities.User | null | undefined, token: string): MenuItem {
if (account) {
return { return {
type: 'user' as const, type: 'user' as const,
user: account, user: account,
active: opts.active != null ? opts.active === account.id : false, active: opts.active != null ? opts.active === id : false,
action: async () => { action: async () => {
if (opts.onChoose) { if (opts.onChoose) {
opts.onChoose(account); opts.onChoose(account);
} else { } else {
switchAccount(host, account.id); switchAccount(host, id);
} }
}, },
}; };
} else {
return {
type: 'button' as const,
text: username,
active: opts.active != null ? opts.active === id : false,
action: async () => {
if (opts.onChoose) {
fetchAccount(token, id).then(account => {
opts.onChoose(account);
});
} else {
switchAccount(host, id);
}
},
};
}
} }
const menuItems: MenuItem[] = []; const menuItems: MenuItem[] = [];
// TODO: $iのホストも比較したいけど通常null // TODO: $iのホストも比較したいけど通常null
const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.user.id !== $i.id))).map(a => createItem(a.host, a.user)); const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id))).map(a => createItem(a.host, a.id, a.username, a.user, a.token));
if (opts.withExtraOperation) { if (opts.withExtraOperation) {
menuItems.push({ menuItems.push({
@ -254,7 +266,7 @@ export async function openAccountMenu(opts: {
}); });
if (opts.includeCurrentAccount) { if (opts.includeCurrentAccount) {
menuItems.push(createItem(host, $i)); menuItems.push(createItem(host, $i.id, $i.username, $i, $i.token));
} }
menuItems.push(...accountItems); menuItems.push(...accountItems);
@ -290,7 +302,7 @@ export async function openAccountMenu(opts: {
}); });
} else { } else {
if (opts.includeCurrentAccount) { if (opts.includeCurrentAccount) {
menuItems.push(createItem(host, $i)); menuItems.push(createItem(host, $i.id, $i.username, $i, $i.token));
} }
menuItems.push(...accountItems); menuItems.push(...accountItems);

View File

@ -157,7 +157,7 @@ async function init() {
const accounts = await getAccounts(); const accounts = await getAccounts();
const accountIdsToFetch = accounts.map(a => a.user.id).filter(id => !users.value.has(id)); const accountIdsToFetch = accounts.map(a => a.id).filter(id => !users.value.has(id));
if (accountIdsToFetch.length > 0) { if (accountIdsToFetch.length > 0) {
const usersRes = await misskeyApi('users/show', { const usersRes = await misskeyApi('users/show', {
@ -169,7 +169,7 @@ async function init() {
users.value.set(user.id, { users.value.set(user.id, {
...user, ...user,
token: accounts.find(a => a.user.id === user.id)!.token, token: accounts.find(a => a.id === user.id)!.token,
}); });
} }
} }

View File

@ -15,12 +15,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</li> </li>
<li tabindex="-1" :class="$style.item" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li> <li tabindex="-1" :class="$style.item" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li>
</ol> </ol>
<ol v-else-if="hashtags.length > 0" ref="suggests" :class="$style.list"> <ol v-else-if="type === 'hashtag' && hashtags.length > 0" ref="suggests" :class="$style.list">
<li v-for="hashtag in hashtags" tabindex="-1" :class="$style.item" @click="complete(type, hashtag)" @keydown="onKeydown"> <li v-for="hashtag in hashtags" tabindex="-1" :class="$style.item" @click="complete(type, hashtag)" @keydown="onKeydown">
<span class="name">{{ hashtag }}</span> <span class="name">{{ hashtag }}</span>
</li> </li>
</ol> </ol>
<ol v-else-if="emojis.length > 0" ref="suggests" :class="$style.list"> <ol v-else-if="type === 'emoji' || type === 'emojiComplete' && emojis.length > 0" ref="suggests" :class="$style.list">
<li v-for="emoji in emojis" :key="emoji.emoji" :class="$style.item" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown"> <li v-for="emoji in emojis" :key="emoji.emoji" :class="$style.item" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown">
<MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji" :fallbackToImage="true"/> <MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji" :fallbackToImage="true"/>
<MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/> <MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/>
@ -30,12 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="emoji.aliasOf" :class="$style.emojiAlias">({{ emoji.aliasOf }})</span> <span v-if="emoji.aliasOf" :class="$style.emojiAlias">({{ emoji.aliasOf }})</span>
</li> </li>
</ol> </ol>
<ol v-else-if="mfmTags.length > 0" ref="suggests" :class="$style.list"> <ol v-else-if="type === 'mfmTag' && mfmTags.length > 0" ref="suggests" :class="$style.list">
<li v-for="tag in mfmTags" tabindex="-1" :class="$style.item" @click="complete(type, tag)" @keydown="onKeydown"> <li v-for="tag in mfmTags" tabindex="-1" :class="$style.item" @click="complete(type, tag)" @keydown="onKeydown">
<span>{{ tag }}</span> <span>{{ tag }}</span>
</li> </li>
</ol> </ol>
<ol v-else-if="mfmParams.length > 0" ref="suggests" :class="$style.list"> <ol v-else-if="type === 'mfmParam' && mfmParams.length > 0" ref="suggests" :class="$style.list">
<li v-for="param in mfmParams" tabindex="-1" :class="$style.item" @click="complete(type, q.params.toSpliced(-1, 1, param).join(','))" @keydown="onKeydown"> <li v-for="param in mfmParams" tabindex="-1" :class="$style.item" @click="complete(type, q.params.toSpliced(-1, 1, param).join(','))" @keydown="onKeydown">
<span>{{ param }}</span> <span>{{ param }}</span>
</li> </li>
@ -58,12 +58,44 @@ import { store } from '@/store.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js'; import { customEmojis } from '@/custom-emojis.js';
import { searchEmoji } from '@/utility/search-emoji.js'; import { searchEmoji, searchEmojiExact } from '@/utility/search-emoji.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
export type CompleteInfo = {
user: {
payload: any;
query: string | null;
},
hashtag: {
payload: string;
query: string;
},
// `:emo` -> `:emoji:` or some unicode emoji
emoji: {
payload: string;
query: string;
},
// like emoji but for `:emoji:` -> unicode emoji
emojiComplete: {
payload: string;
query: string;
},
mfmTag: {
payload: string;
query: string;
},
mfmParam: {
payload: string;
query: {
tag: string;
params: string[];
};
},
};
const lib = emojilist.filter(x => x.category !== 'flags'); const lib = emojilist.filter(x => x.category !== 'flags');
const emojiDb = computed(() => { const unicodeEmojiDB = computed(() => {
//#region Unicode Emoji //#region Unicode Emoji
const char2path = prefer.r.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath; const char2path = prefer.r.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
@ -87,6 +119,12 @@ const emojiDb = computed(() => {
} }
unicodeEmojiDB.sort((a, b) => a.name.length - b.name.length); unicodeEmojiDB.sort((a, b) => a.name.length - b.name.length);
return unicodeEmojiDB;
});
const emojiDb = computed(() => {
//#region Unicode Emoji
//#endregion //#endregion
//#region Custom Emoji //#region Custom Emoji
@ -114,7 +152,7 @@ const emojiDb = computed(() => {
customEmojiDB.sort((a, b) => a.name.length - b.name.length); customEmojiDB.sort((a, b) => a.name.length - b.name.length);
//#endregion //#endregion
return markRaw([...customEmojiDB, ...unicodeEmojiDB]); return markRaw([...customEmojiDB, ...unicodeEmojiDB.value]);
}); });
export default { export default {
@ -123,18 +161,23 @@ export default {
}; };
</script> </script>
<script lang="ts" setup> <script lang="ts" setup generic="T extends keyof CompleteInfo">
const props = defineProps<{ type PropsType<T extends keyof CompleteInfo> = {
type: string; type: T;
q: any; q: CompleteInfo[T]['query'];
textarea: HTMLTextAreaElement; // HTMLTextAreaElement | HTMLInputElement addEventListener/removeEventListener
textarea: (HTMLTextAreaElement | HTMLInputElement) & HTMLElement;
close: () => void; close: () => void;
x: number; x: number;
y: number; y: number;
}>(); };
//const props = defineProps<PropsType<keyof CompleteInfo>>();
// discriminated union
// https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html#discriminated-unions
const props = defineProps<PropsType<'user'> | PropsType<'hashtag'> | PropsType<'emoji'> | PropsType<'emojiComplete'> | PropsType<'mfmTag'> | PropsType<'mfmParam'>>();
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'done', value: { type: string; value: any }): void; <T extends keyof CompleteInfo>(event: 'done', value: { type: T; value: CompleteInfo[T]['payload'] }): void;
(event: 'closed'): void; (event: 'closed'): void;
}>(); }>();
@ -151,10 +194,10 @@ const mfmParams = ref<string[]>([]);
const select = ref(-1); const select = ref(-1);
const zIndex = os.claimZIndex('high'); const zIndex = os.claimZIndex('high');
function complete(type: string, value: any) { function complete<T extends keyof CompleteInfo>(type: T, value: CompleteInfo[T]['payload']) {
emit('done', { type, value }); emit('done', { type, value });
emit('closed'); emit('closed');
if (type === 'emoji') { if (type === 'emoji' || type === 'emojiComplete') {
let recents = store.s.recentlyUsedEmojis; let recents = store.s.recentlyUsedEmojis;
recents = recents.filter((emoji: any) => emoji !== value); recents = recents.filter((emoji: any) => emoji !== value);
recents.unshift(value); recents.unshift(value);
@ -243,6 +286,8 @@ function exec() {
} }
emojis.value = searchEmoji(props.q, emojiDb.value); emojis.value = searchEmoji(props.q, emojiDb.value);
} else if (props.type === 'emojiComplete') {
emojis.value = searchEmojiExact(props.q, unicodeEmojiDB.value);
} else if (props.type === 'mfmTag') { } else if (props.type === 'mfmTag') {
if (!props.q || props.q === '') { if (!props.q || props.q === '') {
mfmTags.value = MFM_TAGS; mfmTags.value = MFM_TAGS;

View File

@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { http, HttpResponse } from 'msw';
import { action } from '@storybook/addon-actions';
import { chatMessage } from '../../.storybook/fakes';
import MkChatHistories from './MkChatHistories.vue';
import type { StoryObj } from '@storybook/vue3';
import type * as Misskey from 'misskey-js';
export const Default = {
render(args) {
return {
components: {
MkChatHistories,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkChatHistories v-bind="props" />',
};
},
parameters: {
layout: 'centered',
msw: {
handlers: [
http.post('/api/chat/history', async ({ request }) => {
const body = await request.json() as Misskey.entities.ChatHistoryRequest;
action('POST /api/chat/history')(body);
return HttpResponse.json([chatMessage(body.room)]);
}),
],
},
},
} satisfies StoryObj<typeof MkChatHistories>;

View File

@ -0,0 +1,208 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="history.length > 0" class="_gaps_s">
<MkA
v-for="item in history"
:key="item.id"
:class="[$style.message, { [$style.isMe]: item.isMe, [$style.isRead]: item.message.isRead }]"
class="_panel"
:to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`"
>
<MkAvatar v-if="item.message.toRoomId" :class="$style.messageAvatar" :user="item.message.fromUser" indicator :preview="false"/>
<MkAvatar v-else-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/>
<div :class="$style.messageBody">
<header v-if="item.message.toRoom" :class="$style.messageHeader">
<span :class="$style.messageHeaderName"><i class="ti ti-users"></i> {{ item.message.toRoom.name }}</span>
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
</header>
<header v-else :class="$style.messageHeader">
<MkUserName :class="$style.messageHeaderName" :user="item.other!"/>
<MkAcct :class="$style.messageHeaderUsername" :user="item.other!"/>
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
</header>
<div :class="$style.messageBodyText"><span v-if="item.isMe" :class="$style.youSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</div>
</div>
</MkA>
</div>
<div v-if="!initializing && history.length == 0" class="_fullinfo">
<div>{{ i18n.ts._chat.noHistory }}</div>
</div>
<MkLoading v-if="initializing"/>
</template>
<script lang="ts" setup>
import { onActivated, onDeactivated, onMounted, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { ensureSignin } from '@/i.js';
const $i = ensureSignin();
const history = ref<{
id: string;
message: Misskey.entities.ChatMessage;
other: Misskey.entities.ChatMessage['fromUser'] | Misskey.entities.ChatMessage['toUser'] | null;
isMe: boolean;
}[]>([]);
const initializing = ref(true);
const fetching = ref(false);
async function fetchHistory() {
if (fetching.value) return;
fetching.value = true;
const [userMessages, roomMessages] = await Promise.all([
misskeyApi('chat/history', { room: false }),
misskeyApi('chat/history', { room: true }),
]);
history.value = [...userMessages, ...roomMessages]
.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map(m => ({
id: m.id,
message: m,
other: (!('room' in m) || m.room == null) ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null,
isMe: m.fromUserId === $i.id,
}));
fetching.value = false;
initializing.value = false;
}
let isActivated = true;
onActivated(() => {
isActivated = true;
});
onDeactivated(() => {
isActivated = false;
});
useInterval(() => {
// TODO: DOM
if (!window.document.hidden && isActivated) {
fetchHistory();
}
}, 1000 * 10, {
immediate: false,
afterMounted: true,
});
onActivated(() => {
fetchHistory();
});
onMounted(() => {
fetchHistory();
});
</script>
<style lang="scss" module>
.message {
position: relative;
display: flex;
padding: 16px 24px;
&.isRead,
&.isMe {
opacity: 0.8;
}
&:not(.isMe):not(.isRead) {
&::before {
content: '';
position: absolute;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
border-radius: 100%;
background-color: var(--MI_THEME-accent);
}
}
}
@container (max-width: 500px) {
.message {
font-size: 90%;
padding: 14px 20px;
}
}
@container (max-width: 450px) {
.message {
font-size: 80%;
padding: 12px 16px;
}
}
.messageAvatar {
width: 50px;
height: 50px;
margin: 0 16px 0 0;
}
@container (max-width: 500px) {
.messageAvatar {
width: 45px;
height: 45px;
}
}
@container (max-width: 450px) {
.messageAvatar {
width: 40px;
height: 40px;
}
}
.messageBody {
flex: 1;
min-width: 0;
}
.messageHeader {
display: flex;
align-items: center;
margin-bottom: 2px;
white-space: nowrap;
overflow: clip;
}
.messageHeaderName {
margin: 0;
padding: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 1em;
font-weight: bold;
}
.messageHeaderUsername {
margin: 0 8px;
}
.messageHeaderTime {
margin-left: auto;
}
.messageBodyText {
overflow: hidden;
overflow-wrap: break-word;
font-size: 1.1em;
}
.youSaid {
font-weight: bold;
margin-right: 0.5em;
}
</style>

View File

@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkDisableSection from './MkDisableSection.vue';
void MkDisableSection;

View File

@ -626,13 +626,13 @@ function getMenu() {
text: i18n.ts.upload + ' (' + i18n.ts.compress + ')', text: i18n.ts.upload + ' (' + i18n.ts.compress + ')',
icon: 'ti ti-upload', icon: 'ti ti-upload',
action: () => { action: () => {
chooseFileFromPc(true, { keepOriginal: false }); chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: false });
}, },
}, { }, {
text: i18n.ts.upload, text: i18n.ts.upload,
icon: 'ti ti-upload', icon: 'ti ti-upload',
action: () => { action: () => {
chooseFileFromPc(true, { keepOriginal: true }); chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: true });
}, },
}, { }, {
text: i18n.ts.fromUrl, text: i18n.ts.fromUrl,

View File

@ -31,9 +31,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-for="item in (items2 ?? [])"> <template v-for="item in (items2 ?? [])">
<div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div> <div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div>
<span v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label, $style.item]"> <div v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label]">
<span style="opacity: 0.7;">{{ item.text }}</span> <span>{{ item.text }}</span>
</span> </div>
<span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item]"> <span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item]">
<span><MkEllipsis/></span> <span><MkEllipsis/></span>
@ -619,12 +619,6 @@ onBeforeUnmount(() => {
--menuActiveBg: var(--MI_THEME-accentedBg); --menuActiveBg: var(--MI_THEME-accentedBg);
} }
&.label {
pointer-events: none;
font-size: 0.7em;
padding-bottom: 4px;
}
&.pending { &.pending {
pointer-events: none; pointer-events: none;
opacity: 0.7; opacity: 0.7;
@ -694,6 +688,19 @@ onBeforeUnmount(() => {
font-size: 12px; font-size: 12px;
} }
.label {
position: relative;
padding: 6px 16px;
box-sizing: border-box;
white-space: nowrap;
font-size: 0.7em;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.7;
pointer-events: none;
}
.divider { .divider {
margin: 8px 0; margin: 8px 0;
border-top: solid 0.5px var(--MI_THEME-divider); border-top: solid 0.5px var(--MI_THEME-divider);

View File

@ -14,7 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{ items: notes }"> <template #default="{ items: notes }">
<component <component
:is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap }]" :is="prefer.s.animation ? TransitionGroup : 'div'"
:class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]"
:enterActiveClass="$style.transition_x_enterActive" :enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive" :leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom" :enterFromClass="$style.transition_x_enterFrom"
@ -23,13 +24,13 @@ SPDX-License-Identifier: AGPL-3.0-only
tag="div" tag="div"
> >
<template v-for="(note, i) in notes" :key="note.id"> <template v-for="(note, i) in notes" :key="note.id">
<div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]"> <div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id">
<MkNote :class="$style.note" :note="note" :withHardMute="true"/> <MkNote :class="$style.note" :note="note" :withHardMute="true"/>
<div :class="$style.ad"> <div :class="$style.ad">
<MkAd :preferForms="['horizontal', 'horizontal-big']"/> <MkAd :preferForms="['horizontal', 'horizontal-big']"/>
</div> </div>
</div> </div>
<MkNote v-else :class="$style.note" :note="note" :withHardMute="true"/> <MkNote :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
</template> </template>
</component> </component>
</template> </template>
@ -73,6 +74,11 @@ defineExpose({
position: absolute; position: absolute;
} }
.reverse {
display: flex;
flex-direction: column-reverse;
}
.root { .root {
container-type: inline-size; container-type: inline-size;

View File

@ -296,6 +296,7 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
right: -2px; right: -2px;
width: 20px; width: 20px;
height: 20px; height: 20px;
line-height: 20px;
box-sizing: border-box; box-sizing: border-box;
border-radius: 100%; border-radius: 100%;
background: var(--MI_THEME-panel); background: var(--MI_THEME-panel);
@ -310,73 +311,61 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
} }
.t_follow, .t_followRequestAccepted, .t_receiveFollowRequest { .t_follow, .t_followRequestAccepted, .t_receiveFollowRequest {
padding: 3px;
background: var(--eventFollow); background: var(--eventFollow);
pointer-events: none; pointer-events: none;
} }
.t_renote { .t_renote {
padding: 3px;
background: var(--eventRenote); background: var(--eventRenote);
pointer-events: none; pointer-events: none;
} }
.t_quote { .t_quote {
padding: 3px;
background: var(--eventRenote); background: var(--eventRenote);
pointer-events: none; pointer-events: none;
} }
.t_reply { .t_reply {
padding: 3px;
background: var(--eventReply); background: var(--eventReply);
pointer-events: none; pointer-events: none;
} }
.t_mention { .t_mention {
padding: 3px;
background: var(--eventOther); background: var(--eventOther);
pointer-events: none; pointer-events: none;
} }
.t_pollEnded { .t_pollEnded {
padding: 3px;
background: var(--eventOther); background: var(--eventOther);
pointer-events: none; pointer-events: none;
} }
.t_achievementEarned { .t_achievementEarned {
padding: 3px;
background: var(--eventAchievement); background: var(--eventAchievement);
pointer-events: none; pointer-events: none;
} }
.t_exportCompleted { .t_exportCompleted {
padding: 3px;
background: var(--eventOther); background: var(--eventOther);
pointer-events: none; pointer-events: none;
} }
.t_roleAssigned { .t_roleAssigned {
padding: 3px;
background: var(--eventOther); background: var(--eventOther);
pointer-events: none; pointer-events: none;
} }
.t_login { .t_login {
padding: 3px;
background: var(--eventLogin); background: var(--eventLogin);
pointer-events: none; pointer-events: none;
} }
.t_createToken { .t_createToken {
padding: 3px;
background: var(--eventOther); background: var(--eventOther);
pointer-events: none; pointer-events: none;
} }
.t_chatRoomInvitationReceived { .t_chatRoomInvitationReceived {
padding: 3px;
background: var(--eventOther); background: var(--eventOther);
pointer-events: none; pointer-events: none;
} }

View File

@ -24,8 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only
tag="div" tag="div"
> >
<template v-for="(notification, i) in notifications" :key="notification.id"> <template v-for="(notification, i) in notifications" :key="notification.id">
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true"/> <MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true" :data-scroll-anchor="notification.id"/>
<XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true"/> <XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true" :data-scroll-anchor="notification.id"/>
</template> </template>
</component> </component>
</template> </template>

View File

@ -879,7 +879,7 @@ async function post(ev?: MouseEvent) {
if (postAccount.value) { if (postAccount.value) {
const storedAccounts = await getAccounts(); const storedAccounts = await getAccounts();
token = storedAccounts.find(x => x.user.id === postAccount.value?.id)?.token; token = storedAccounts.find(x => x.id === postAccount.value?.id)?.token;
} }
posting.value = true; posting.value = true;

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<g fill-rule="evenodd"> <g fill-rule="evenodd">
<rect width="200" height="150" :fill="themeVariables.bg"/> <rect width="200" height="150" :fill="themeVariables.bg"/>
<rect width="64" height="150" :fill="themeVariables.navBg"/> <rect width="64" height="150" :fill="themeVariables.navBg"/>
<rect x="64" width="136" height="41" :fill="themeVariables.bg"/> <rect x="64" width="136" height="41" :fill="themeVariables.pageHeaderBg"/>
<path transform="scale(.26458)" d="m439.77 247.19c-43.673 0-78.832 35.157-78.832 78.83v249.98h407.06v-328.81z" :fill="themeVariables.panel"/> <path transform="scale(.26458)" d="m439.77 247.19c-43.673 0-78.832 35.157-78.832 78.83v249.98h407.06v-328.81z" :fill="themeVariables.panel"/>
</g> </g>
<circle cx="32" cy="83" r="21" :fill="themeVariables.accentedBg"/> <circle cx="32" cy="83" r="21" :fill="themeVariables.accentedBg"/>
@ -62,6 +62,7 @@ const themeVariables = ref<{
accent: string; accent: string;
accentedBg: string; accentedBg: string;
navBg: string; navBg: string;
pageHeaderBg: string;
success: string; success: string;
warn: string; warn: string;
error: string; error: string;
@ -76,6 +77,7 @@ const themeVariables = ref<{
accent: 'var(--MI_THEME-accent)', accent: 'var(--MI_THEME-accent)',
accentedBg: 'var(--MI_THEME-accentedBg)', accentedBg: 'var(--MI_THEME-accentedBg)',
navBg: 'var(--MI_THEME-navBg)', navBg: 'var(--MI_THEME-navBg)',
pageHeaderBg: 'var(--MI_THEME-pageHeaderBg)',
success: 'var(--MI_THEME-success)', success: 'var(--MI_THEME-success)',
warn: 'var(--MI_THEME-warn)', warn: 'var(--MI_THEME-warn)',
error: 'var(--MI_THEME-error)', error: 'var(--MI_THEME-error)',
@ -104,6 +106,7 @@ watch(() => props.theme, (theme) => {
accent: compiled.accent ?? 'var(--MI_THEME-accent)', accent: compiled.accent ?? 'var(--MI_THEME-accent)',
accentedBg: compiled.accentedBg ?? 'var(--MI_THEME-accentedBg)', accentedBg: compiled.accentedBg ?? 'var(--MI_THEME-accentedBg)',
navBg: compiled.navBg ?? 'var(--MI_THEME-navBg)', navBg: compiled.navBg ?? 'var(--MI_THEME-navBg)',
pageHeaderBg: compiled.pageHeaderBg ?? 'var(--MI_THEME-pageHeaderBg)',
success: compiled.success ?? 'var(--MI_THEME-success)', success: compiled.success ?? 'var(--MI_THEME-success)',
warn: compiled.warn ?? 'var(--MI_THEME-warn)', warn: compiled.warn ?? 'var(--MI_THEME-warn)',
error: compiled.error ?? 'var(--MI_THEME-error)', error: compiled.error ?? 'var(--MI_THEME-error)',

View File

@ -124,11 +124,18 @@ onUnmounted(() => {
<style lang="scss" module> <style lang="scss" module>
.root { .root {
background: color(from var(--MI_THEME-bg) srgb r g b / 0.75); background: color(from var(--MI_THEME-pageHeaderBg) srgb r g b / 0.75);
-webkit-backdrop-filter: var(--MI-blur, blur(15px)); -webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px)); backdrop-filter: var(--MI-blur, blur(15px));
border-bottom: solid 0.5px var(--MI_THEME-divider); border-bottom: solid 0.5px transparent;
width: 100%; width: 100%;
color: var(--MI_THEME-pageHeaderFg);
}
@container style(--MI_THEME-pageHeaderBg: var(--MI_THEME-bg)) {
.root {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
} }
.upper, .upper,

View File

@ -20,6 +20,7 @@ import { useTemplateRef } from 'vue';
import { scrollInContainer } from '@@/js/scroll.js'; import { scrollInContainer } from '@@/js/scroll.js';
import type { PageHeaderItem } from '@/types/page-header.js'; import type { PageHeaderItem } from '@/types/page-header.js';
import type { Tab } from './MkPageHeader.tabs.vue'; import type { Tab } from './MkPageHeader.tabs.vue';
import { useScrollPositionKeeper } from '@/use/use-scroll-position-keeper.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
tabs?: Tab[]; tabs?: Tab[];
@ -35,6 +36,8 @@ const props = withDefaults(defineProps<{
const tab = defineModel<string>('tab'); const tab = defineModel<string>('tab');
const rootEl = useTemplateRef('rootEl'); const rootEl = useTemplateRef('rootEl');
useScrollPositionKeeper(rootEl);
defineExpose({ defineExpose({
scrollToTop: () => { scrollToTop: () => {
if (rootEl.value) scrollInContainer(rootEl.value, { top: 0, behavior: 'smooth' }); if (rootEl.value) scrollInContainer(rootEl.value, { top: 0, behavior: 'smooth' });

View File

@ -38,6 +38,7 @@ export const columnTypes = [
'mentions', 'mentions',
'direct', 'direct',
'roleTimeline', 'roleTimeline',
'chat',
] as const; ] as const;
export type ColumnType = typeof columnTypes[number]; export type ColumnType = typeof columnTypes[number];

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSwiper v-model:tab="tab" :tabs="headerTabs">
<MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20"> <MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20">
<XOverview/> <XOverview/>
</MkSpacer> </MkSpacer>
@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer v-else-if="tab === 'charts'" :contentMax="1000" :marginMin="20"> <MkSpacer v-else-if="tab === 'charts'" :contentMax="1000" :marginMin="20">
<MkInstanceStats/> <MkInstanceStats/>
</MkSpacer> </MkSpacer>
</MkHorizontalSwipe> </MkSwiper>
</PageWithHeader> </PageWithHeader>
</template> </template>
@ -28,7 +28,7 @@ import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { claimAchievement } from '@/utility/achievements.js'; import { claimAchievement } from '@/utility/achievements.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkSwiper from '@/components/MkSwiper.vue';
const XOverview = defineAsyncComponent(() => import('@/pages/about.overview.vue')); const XOverview = defineAsyncComponent(() => import('@/pages/about.overview.vue'));
const XEmojis = defineAsyncComponent(() => import('@/pages/about.emojis.vue')); const XEmojis = defineAsyncComponent(() => import('@/pages/about.emojis.vue'));

View File

@ -10,14 +10,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<XQueue v-if="tab === 'deliver'" domain="deliver"/> <XQueue v-if="tab === 'deliver'" domain="deliver"/>
<XQueue v-else-if="tab === 'inbox'" domain="inbox"/> <XQueue v-else-if="tab === 'inbox'" domain="inbox"/>
<br> <br>
<div class="_buttons">
<MkButton @click="promoteAllQueues"><i class="ti ti-reload"></i> {{ i18n.ts.retryAllQueuesNow }}</MkButton> <MkButton @click="promoteAllQueues"><i class="ti ti-reload"></i> {{ i18n.ts.retryAllQueuesNow }}</MkButton>
<MkButton danger @click="clear"><i class="ti ti-trash"></i> {{ i18n.ts.clearQueue }}</MkButton>
</div>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import * as config from '@@/js/config.js';
import XQueue from './queue.chart.vue'; import XQueue from './queue.chart.vue';
import XHeader from './_header_.vue'; import XHeader from './_header_.vue';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
@ -38,7 +40,7 @@ function clear() {
}).then(({ canceled }) => { }).then(({ canceled }) => {
if (canceled) return; if (canceled) return;
os.apiWithDialog('admin/queue/clear'); os.apiWithDialog('admin/queue/clear', { type: tab.value, state: '*' });
}); });
} }

View File

@ -23,10 +23,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i> <i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i>
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--MI_THEME-success);"></i> <i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--MI_THEME-success);"></i>
</span> </span>
<Mfm :text="announcement.title"/> <Mfm :text="announcement.title" class="_selectable"/>
</div> </div>
<div :class="$style.content"> <div :class="$style.content">
<Mfm :text="announcement.text"/> <Mfm :text="announcement.text" class="_selectable"/>
<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
<div style="margin-top: 8px; opacity: 0.7; font-size: 85%;"> <div style="margin-top: 8px; opacity: 0.7; font-size: 85%;">
{{ i18n.ts.createdAt }}: <MkTime :time="announcement.createdAt" mode="detail"/> {{ i18n.ts.createdAt }}: <MkTime :time="announcement.createdAt" mode="detail"/>

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="800"> <MkSpacer :contentMax="800">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div class="_gaps"> <div class="_gaps">
<MkInfo v-if="$i && $i.hasUnreadAnnouncement && tab === 'current'" warn>{{ i18n.ts.youHaveUnreadAnnouncements }}</MkInfo> <MkInfo v-if="$i && $i.hasUnreadAnnouncement && tab === 'current'" warn>{{ i18n.ts.youHaveUnreadAnnouncements }}</MkInfo>
<MkPagination ref="paginationEl" :key="tab" v-slot="{items}" :pagination="tab === 'current' ? paginationCurrent : paginationPast" class="_gaps"> <MkPagination ref="paginationEl" :key="tab" v-slot="{items}" :pagination="tab === 'current' ? paginationCurrent : paginationPast" class="_gaps">
@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA :to="`/announcements/${announcement.id}`"><span>{{ announcement.title }}</span></MkA> <MkA :to="`/announcements/${announcement.id}`"><span>{{ announcement.title }}</span></MkA>
</div> </div>
<div :class="$style.content"> <div :class="$style.content">
<Mfm :text="announcement.text"/> <Mfm :text="announcement.text" class="_selectable"/>
<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
<MkA :to="`/announcements/${announcement.id}`"> <MkA :to="`/announcements/${announcement.id}`">
<div style="margin-top: 8px; opacity: 0.7; font-size: 85%;"> <div style="margin-top: 8px; opacity: 0.7; font-size: 85%;">
@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</section> </section>
</MkPagination> </MkPagination>
</div> </div>
</MkHorizontalSwipe> </MkSwiper>
</MkSpacer> </MkSpacer>
</PageWithHeader> </PageWithHeader>
</template> </template>
@ -50,7 +50,7 @@ import { ref, computed } from 'vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkSwiper from '@/components/MkSwiper.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="700"> <MkSpacer :contentMax="700">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div v-if="channel && tab === 'overview'" class="_gaps"> <div v-if="channel && tab === 'overview'" class="_gaps">
<div class="_panel" :class="$style.bannerContainer"> <div class="_panel" :class="$style.bannerContainer">
<XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/> <XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/>
@ -57,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo> <MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo>
</div> </div>
</div> </div>
</MkHorizontalSwipe> </MkSwiper>
</MkSpacer> </MkSpacer>
<template #footer> <template #footer>
<div :class="$style.footer"> <div :class="$style.footer">
@ -93,7 +93,7 @@ import { prefer } from '@/preferences.js';
import MkNote from '@/components/MkNote.vue'; import MkNote from '@/components/MkNote.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkSwiper from '@/components/MkSwiper.vue';
import { isSupportShare } from '@/utility/navigator.js'; import { isSupportShare } from '@/utility/navigator.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { notesSearchAvailable } from '@/utility/check-permissions.js'; import { notesSearchAvailable } from '@/utility/check-permissions.js';

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="1200"> <MkSpacer :contentMax="1200">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div v-if="tab === 'search'" :class="$style.searchRoot"> <div v-if="tab === 'search'" :class="$style.searchRoot">
<div class="_gaps"> <div class="_gaps">
<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search"> <MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search">
@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkPagination> </MkPagination>
</div> </div>
</MkHorizontalSwipe> </MkSwiper>
</MkSpacer> </MkSpacer>
</PageWithHeader> </PageWithHeader>
</template> </template>
@ -67,7 +67,7 @@ import MkInput from '@/components/MkInput.vue';
import MkRadios from '@/components/MkRadios.vue'; import MkRadios from '@/components/MkRadios.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkSwiper from '@/components/MkSwiper.vue';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { useRouter } from '@/router.js'; import { useRouter } from '@/router.js';

View File

@ -34,34 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFoldableSection> <MkFoldableSection>
<template #header>{{ i18n.ts._chat.history }}</template> <template #header>{{ i18n.ts._chat.history }}</template>
<div v-if="history.length > 0" class="_gaps_s"> <MkChatHistories/>
<MkA
v-for="item in history"
:key="item.id"
:class="[$style.message, { [$style.isMe]: item.isMe, [$style.isRead]: item.message.isRead }]"
class="_panel"
:to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`"
>
<MkAvatar v-if="item.message.toRoomId" :class="$style.messageAvatar" :user="item.message.fromUser" indicator :preview="false"/>
<MkAvatar v-else-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/>
<div :class="$style.messageBody">
<header v-if="item.message.toRoom" :class="$style.messageHeader">
<span :class="$style.messageHeaderName"><i class="ti ti-users"></i> {{ item.message.toRoom.name }}</span>
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
</header>
<header v-else :class="$style.messageHeader">
<MkUserName :class="$style.messageHeaderName" :user="item.other!"/>
<MkAcct :class="$style.messageHeaderUsername" :user="item.other!"/>
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
</header>
<div :class="$style.messageBodyText"><span v-if="item.isMe" :class="$style.youSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</div>
</div>
</MkA>
</div>
<div v-if="!initializing && history.length == 0" class="_fullinfo">
<div>{{ i18n.ts._chat.noHistory }}</div>
</div>
<MkLoading v-if="initializing"/>
</MkFoldableSection> </MkFoldableSection>
</div> </div>
</template> </template>
@ -81,20 +54,12 @@ import { updateCurrentAccountPartial } from '@/accounts.js';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import MkChatHistories from '@/components/MkChatHistories.vue';
const $i = ensureSignin(); const $i = ensureSignin();
const router = useRouter(); const router = useRouter();
const initializing = ref(true);
const fetching = ref(false);
const history = ref<{
id: string;
message: Misskey.entities.ChatMessage;
other: Misskey.entities.ChatMessage['fromUser'] | Misskey.entities.ChatMessage['toUser'] | null;
isMe: boolean;
}[]>([]);
const searchQuery = ref(''); const searchQuery = ref('');
const searched = ref(false); const searched = ref(false);
const searchResults = ref<Misskey.entities.ChatMessage[]>([]); const searchResults = ref<Misskey.entities.ChatMessage[]>([]);
@ -148,57 +113,8 @@ async function search() {
searched.value = true; searched.value = true;
} }
async function fetchHistory() {
if (fetching.value) return;
fetching.value = true;
const [userMessages, roomMessages] = await Promise.all([
misskeyApi('chat/history', { room: false }),
misskeyApi('chat/history', { room: true }),
]);
history.value = [...userMessages, ...roomMessages]
.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map(m => ({
id: m.id,
message: m,
other: (!('room' in m) || m.room == null) ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null,
isMe: m.fromUserId === $i.id,
}));
fetching.value = false;
initializing.value = false;
updateCurrentAccountPartial({ hasUnreadChatMessages: false });
}
let isActivated = true;
onActivated(() => {
isActivated = true;
});
onDeactivated(() => {
isActivated = false;
});
useInterval(() => {
// TODO: DOM
if (!window.document.hidden && isActivated) {
fetchHistory();
}
}, 1000 * 10, {
immediate: false,
afterMounted: true,
});
onActivated(() => {
fetchHistory();
});
onMounted(() => { onMounted(() => {
fetchHistory(); updateCurrentAccountPartial({ hasUnreadChatMessages: false });
}); });
</script> </script>
@ -207,77 +123,6 @@ onMounted(() => {
margin: 0 auto; margin: 0 auto;
} }
.message {
position: relative;
display: flex;
padding: 16px 24px;
&.isRead,
&.isMe {
opacity: 0.8;
}
&:not(.isMe):not(.isRead) {
&::before {
content: '';
position: absolute;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
border-radius: 100%;
background-color: var(--MI_THEME-accent);
}
}
}
.messageAvatar {
width: 50px;
height: 50px;
margin: 0 16px 0 0;
}
.messageBody {
flex: 1;
min-width: 0;
}
.messageHeader {
display: flex;
align-items: center;
margin-bottom: 2px;
white-space: nowrap;
overflow: clip;
}
.messageHeaderName {
margin: 0;
padding: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 1em;
font-weight: bold;
}
.messageHeaderUsername {
margin: 0 8px;
}
.messageHeaderTime {
margin-left: auto;
}
.messageBodyText {
overflow: hidden;
overflow-wrap: break-word;
font-size: 1.1em;
}
.youSaid {
font-weight: bold;
margin-right: 0.5em;
}
.searchResultItem { .searchResultItem {
padding: 12px; padding: 12px;
border: solid 1px var(--MI_THEME-divider); border: solid 1px var(--MI_THEME-divider);

View File

@ -7,12 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkPolkadots v-if="tab === 'home'" accented/> <MkPolkadots v-if="tab === 'home'" accented/>
<MkSpacer :contentMax="700"> <MkSpacer :contentMax="700">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSwiper v-model:tab="tab" :tabs="headerTabs">
<XHome v-if="tab === 'home'"/> <XHome v-if="tab === 'home'"/>
<XInvitations v-else-if="tab === 'invitations'"/> <XInvitations v-else-if="tab === 'invitations'"/>
<XJoiningRooms v-else-if="tab === 'joiningRooms'"/> <XJoiningRooms v-else-if="tab === 'joiningRooms'"/>
<XOwnedRooms v-else-if="tab === 'ownedRooms'"/> <XOwnedRooms v-else-if="tab === 'ownedRooms'"/>
</MkHorizontalSwipe> </MkSwiper>
</MkSpacer> </MkSpacer>
</PageWithHeader> </PageWithHeader>
</template> </template>
@ -25,7 +25,7 @@ import XJoiningRooms from './home.joiningRooms.vue';
import XOwnedRooms from './home.ownedRooms.vue'; import XOwnedRooms from './home.ownedRooms.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkSwiper from '@/components/MkSwiper.vue';
import MkPolkadots from '@/components/MkPolkadots.vue'; import MkPolkadots from '@/components/MkPolkadots.vue';
const tab = ref('home'); const tab = ref('home');

View File

@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/> <MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/>
</template> </template>
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSwiper v-model:tab="tab" :tabs="headerTabs">
<MkSpacer v-if="tab === 'info'" :contentMax="800"> <MkSpacer v-if="tab === 'info'" :contentMax="800">
<XFileInfo :fileId="fileId"/> <XFileInfo :fileId="fileId"/>
</MkSpacer> </MkSpacer>
@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer v-else-if="tab === 'notes'" :contentMax="800"> <MkSpacer v-else-if="tab === 'notes'" :contentMax="800">
<XNotes :fileId="fileId"/> <XNotes :fileId="fileId"/>
</MkSpacer> </MkSpacer>
</MkHorizontalSwipe> </MkSwiper>
</MkStickyContainer> </MkStickyContainer>
</template> </template>
@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, ref, defineAsyncComponent } from 'vue'; import { computed, ref, defineAsyncComponent } from 'vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkSwiper from '@/components/MkSwiper.vue';
const props = defineProps<{ const props = defineProps<{
fileId: string; fileId: string;

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div v-if="tab === 'featured'"> <div v-if="tab === 'featured'">
<XFeatured/> <XFeatured/>
</div> </div>
@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else-if="tab === 'roles'"> <div v-else-if="tab === 'roles'">
<XRoles/> <XRoles/>
</div> </div>
</MkHorizontalSwipe> </MkSwiper>
</PageWithHeader> </PageWithHeader>
</template> </template>
@ -25,7 +25,7 @@ import XFeatured from './explore.featured.vue';
import XUsers from './explore.users.vue'; import XUsers from './explore.users.vue';
import XRoles from './explore.roles.vue'; import XRoles from './explore.roles.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkSwiper from '@/components/MkSwiper.vue';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="700"> <MkSpacer :contentMax="700">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div v-if="tab === 'featured'"> <div v-if="tab === 'featured'">
<MkPagination v-slot="{items}" :pagination="featuredFlashsPagination"> <MkPagination v-slot="{items}" :pagination="featuredFlashsPagination">
<div class="_gaps_s"> <div class="_gaps_s">
@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkPagination> </MkPagination>
</div> </div>
</MkHorizontalSwipe> </MkSwiper>
</MkSpacer> </MkSpacer>
</PageWithHeader> </PageWithHeader>
</template> </template>
@ -43,7 +43,7 @@ import { computed, ref } from 'vue';
import MkFlashPreview from '@/components/MkFlashPreview.vue'; import MkFlashPreview from '@/components/MkFlashPreview.vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkSwiper from '@/components/MkSwiper.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { useRouter } from '@/router.js'; import { useRouter } from '@/router.js';

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="800"> <MkSpacer :contentMax="800">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSwiper v-model:tab="tab" :tabs="headerTabs">
<MkPagination ref="paginationComponent" :pagination="pagination"> <MkPagination ref="paginationComponent" :pagination="pagination">
<template #empty> <template #empty>
<div class="_fullinfo"> <div class="_fullinfo">
@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</template> </template>
</MkPagination> </MkPagination>
</MkHorizontalSwipe> </MkSwiper>
</MkSpacer> </MkSpacer>
</PageWithHeader> </PageWithHeader>
</template> </template>
@ -52,7 +52,7 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { infoImageUrl } from '@/instance.js'; import { infoImageUrl } from '@/instance.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkSwiper from '@/components/MkSwiper.vue';
const paginationComponent = useTemplateRef('paginationComponent'); const paginationComponent = useTemplateRef('paginationComponent');

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="1400"> <MkSpacer :contentMax="1400">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div v-if="tab === 'explore'"> <div v-if="tab === 'explore'">
<MkFoldableSection class="_margin"> <MkFoldableSection class="_margin">
<template #header><i class="ti ti-clock"></i>{{ i18n.ts.recentPosts }}</template> <template #header><i class="ti ti-clock"></i>{{ i18n.ts.recentPosts }}</template>
@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkPagination> </MkPagination>
</div> </div>
</MkHorizontalSwipe> </MkSwiper>
</MkSpacer> </MkSpacer>
</PageWithHeader> </PageWithHeader>
</template> </template>
@ -50,7 +50,7 @@ import { watch, ref, computed } from 'vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue'; import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkSwiper from '@/components/MkSwiper.vue';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { useRouter } from '@/router.js'; import { useRouter } from '@/router.js';

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer v-if="instance" :contentMax="600" :marginMin="16" :marginMax="32"> <MkSpacer v-if="instance" :contentMax="600" :marginMin="16" :marginMax="32">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div v-if="tab === 'overview'" class="_gaps_m"> <div v-if="tab === 'overview'" class="_gaps_m">
<div class="fnfelxur"> <div class="fnfelxur">
<img :src="faviconUrl" alt="" class="icon"/> <img :src="faviconUrl" alt="" class="icon"/>
@ -126,7 +126,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkObjectView tall :value="instance"> <MkObjectView tall :value="instance">
</MkObjectView> </MkObjectView>
</div> </div>
</MkHorizontalSwipe> </MkSwiper>
</MkSpacer> </MkSpacer>
</PageWithHeader> </PageWithHeader>
</template> </template>
@ -153,7 +153,7 @@ import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue'; import MkUserCardMini from '@/components/MkUserCardMini.vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkSwiper from '@/components/MkSwiper.vue';
import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js'; import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
import { dateString } from '@/filters/date.js'; import { dateString } from '@/filters/date.js';
import MkTextarea from '@/components/MkTextarea.vue'; import MkTextarea from '@/components/MkTextarea.vue';

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="700"> <MkSpacer :contentMax="700">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div v-if="tab === 'my'" class="_gaps"> <div v-if="tab === 'my'" class="_gaps">
<MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else-if="tab === 'favorites'" class="_gaps"> <div v-else-if="tab === 'favorites'" class="_gaps">
<MkClipPreview v-for="item in favorites" :key="item.id" :clip="item"/> <MkClipPreview v-for="item in favorites" :key="item.id" :clip="item"/>
</div> </div>
</MkHorizontalSwipe> </MkSwiper>
</MkSpacer> </MkSpacer>
</PageWithHeader> </PageWithHeader>
</template> </template>
@ -33,7 +33,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { clipsCache } from '@/cache.js'; import { clipsCache } from '@/cache.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkSwiper from '@/components/MkSwiper.vue';
const pagination = { const pagination = {
endpoint: 'clips/list' as const, endpoint: 'clips/list' as const,

View File

@ -24,7 +24,7 @@ import { computed, ref } from 'vue';
import { notificationTypes } from '@@/js/const.js'; import { notificationTypes } from '@@/js/const.js';
import XNotifications from '@/components/MkNotifications.vue'; import XNotifications from '@/components/MkNotifications.vue';
import MkNotes from '@/components/MkNotes.vue'; import MkNotes from '@/components/MkNotes.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkSwiper from '@/components/MkSwiper.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="700"> <MkSpacer :contentMax="700">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div v-if="tab === 'featured'"> <div v-if="tab === 'featured'">
<MkPagination v-slot="{items}" :pagination="featuredPagesPagination"> <MkPagination v-slot="{items}" :pagination="featuredPagesPagination">
<div class="_gaps"> <div class="_gaps">
@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</MkPagination> </MkPagination>
</div> </div>
</MkHorizontalSwipe> </MkSwiper>
</MkSpacer> </MkSpacer>
</PageWithHeader> </PageWithHeader>
</template> </template>
@ -41,7 +41,7 @@ import { computed, ref } from 'vue';
import MkPagePreview from '@/components/MkPagePreview.vue'; import MkPagePreview from '@/components/MkPagePreview.vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkSwiper from '@/components/MkSwiper.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { useRouter } from '@/router.js'; import { useRouter } from '@/router.js';

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> <PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSwiper v-model:tab="tab" :tabs="headerTabs">
<MkSpacer v-if="tab === 'note'" :contentMax="800"> <MkSpacer v-if="tab === 'note'" :contentMax="800">
<div v-if="notesSearchAvailable || ignoreNotesSearchAvailable"> <div v-if="notesSearchAvailable || ignoreNotesSearchAvailable">
<XNote v-bind="props"/> <XNote v-bind="props"/>
@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer v-else-if="tab === 'user'" :contentMax="800"> <MkSpacer v-else-if="tab === 'user'" :contentMax="800">
<XUser v-bind="props"/> <XUser v-bind="props"/>
</MkSpacer> </MkSpacer>
</MkHorizontalSwipe> </MkSwiper>
</PageWithHeader> </PageWithHeader>
</template> </template>
@ -28,7 +28,7 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { notesSearchAvailable } from '@/utility/check-permissions.js'; import { notesSearchAvailable } from '@/utility/check-permissions.js';
import MkInfo from '@/components/MkInfo.vue'; import MkInfo from '@/components/MkInfo.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkSwiper from '@/components/MkSwiper.vue';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
query?: string, query?: string,

View File

@ -177,7 +177,8 @@ const menuDef = computed<SuperMenuDef[]>(() => [{
action: async () => { action: async () => {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'warning', type: 'warning',
text: i18n.ts.logoutConfirm, title: i18n.ts.logoutConfirm,
text: i18n.ts.logoutWillClearClientData,
}); });
if (canceled) return; if (canceled) return;
signout(); signout();

View File

@ -52,12 +52,15 @@ import { miLocalStorage } from '@/local-storage.js';
import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { useRouter } from '@/router.js'; import { useRouter } from '@/router.js';
import { useScrollPositionKeeper } from '@/use/use-scroll-position-keeper.js';
provide('shouldOmitHeaderTitle', true); provide('shouldOmitHeaderTitle', true);
const tlComponent = useTemplateRef('tlComponent'); const tlComponent = useTemplateRef('tlComponent');
const rootEl = useTemplateRef('rootEl'); const rootEl = useTemplateRef('rootEl');
useScrollPositionKeeper(rootEl);
const router = useRouter(); const router = useRouter();
router.useListener('same', () => { router.useListener('same', () => {
top(); top();

View File

@ -51,7 +51,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="user.followedMessage != null" class="followedMessage"> <div v-if="user.followedMessage != null" class="followedMessage">
<MkFukidashi class="fukidashi" :tail="narrow ? 'none' : 'left'" negativeMargin> <MkFukidashi class="fukidashi" :tail="narrow ? 'none' : 'left'" negativeMargin>
<div class="messageHeader">{{ i18n.ts.messageToFollower }}</div> <div class="messageHeader">{{ i18n.ts.messageToFollower }}</div>
<div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user"/></MkSparkle></div> <div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user" class="_selectable"/></MkSparkle></div>
</MkFukidashi> </MkFukidashi>
</div> </div>
<div v-if="user.roles.length > 0" class="roles"> <div v-if="user.roles.length > 0" class="roles">
@ -84,7 +84,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div class="description"> <div class="description">
<MkOmit> <MkOmit>
<Mfm v-if="user.description" :text="user.description" :isNote="false" :author="user"/> <Mfm v-if="user.description" :text="user.description" :isNote="false" :author="user" class="_selectable"/>
<p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p> <p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p>
</MkOmit> </MkOmit>
</div> </div>
@ -105,10 +105,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="user.fields.length > 0" class="fields"> <div v-if="user.fields.length > 0" class="fields">
<dl v-for="(field, i) in user.fields" :key="i" class="field"> <dl v-for="(field, i) in user.fields" :key="i" class="field">
<dt class="name"> <dt class="name">
<Mfm :text="field.name" :author="user" :plain="true" :colored="false"/> <Mfm :text="field.name" :author="user" :plain="true" :colored="false" class="_selectable"/>
</dt> </dt>
<dd class="value"> <dd class="value">
<Mfm :text="field.value" :author="user" :colored="false"/> <Mfm :text="field.value" :author="user" :colored="false" class="_selectable"/>
<i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ti ti-circle-check" :class="$style.verifiedLink"></i> <i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ti ti-circle-check" :class="$style.verifiedLink"></i>
</dd> </dd>
</dl> </dl>

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<PageWithHeader v-model:tab="tab" :tabs="headerTabs" :actions="headerActions"> <PageWithHeader v-model:tab="tab" :tabs="headerTabs" :actions="headerActions">
<div v-if="user"> <div v-if="user">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs"> <MkSwiper v-model:tab="tab" :tabs="headerTabs">
<XHome v-if="tab === 'home'" :user="user" @unfoldFiles="() => { tab = 'files'; }"/> <XHome v-if="tab === 'home'" :user="user" @unfoldFiles="() => { tab = 'files'; }"/>
<MkSpacer v-else-if="tab === 'notes'" :contentMax="800" style="padding-top: 0"> <MkSpacer v-else-if="tab === 'notes'" :contentMax="800" style="padding-top: 0">
<XTimeline :user="user"/> <XTimeline :user="user"/>
@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<XFlashs v-else-if="tab === 'flashs'" :user="user"/> <XFlashs v-else-if="tab === 'flashs'" :user="user"/>
<XGallery v-else-if="tab === 'gallery'" :user="user"/> <XGallery v-else-if="tab === 'gallery'" :user="user"/>
<XRaw v-else-if="tab === 'raw'" :user="user"/> <XRaw v-else-if="tab === 'raw'" :user="user"/>
</MkHorizontalSwipe> </MkSwiper>
</div> </div>
<MkError v-else-if="error" @retry="fetchUser()"/> <MkError v-else-if="error" @retry="fetchUser()"/>
<MkLoading v-else/> <MkLoading v-else/>
@ -36,7 +36,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue'; import MkSwiper from '@/components/MkSwiper.vue';
import { serverContext, assertServerContext } from '@/server-context.js'; import { serverContext, assertServerContext } from '@/server-context.js';
const XHome = defineAsyncComponent(() => import('./home.vue')); const XHome = defineAsyncComponent(() => import('./home.vue'));

View File

@ -32,10 +32,11 @@ export type SoundStore = {
// NOTE: デフォルト値は他の設定の状態に依存してはならない(依存していた場合、ユーザーがその設定項目単体で「初期値にリセット」した場合不具合の原因になる) // NOTE: デフォルト値は他の設定の状態に依存してはならない(依存していた場合、ユーザーがその設定項目単体で「初期値にリセット」した場合不具合の原因になる)
export const PREF_DEF = { export const PREF_DEF = {
// TODO: 持つのはホストやユーザーID、ユーザー名など最低限にしといて、その他のプロフィール情報はpreferences外で管理した方が綺麗そう
// 現状だと、updateCurrentAccount/updateCurrentAccountPartialが呼ばれるたびに「設定」へのcommitが行われて不自然(明らかに設定の更新とは捉えにくい)だし
accounts: { accounts: {
default: [] as [host: string, user: Misskey.entities.User][], default: [] as [host: string, user: {
id: string;
username: string;
}][],
}, },
pinnedUserLists: { pinnedUserLists: {

View File

@ -4,28 +4,48 @@
*/ */
import { apiUrl } from '@@/js/config.js'; import { apiUrl } from '@@/js/config.js';
import { defaultMemoryStorage } from '@/memory-storage'; import { cloudBackup } from '@/preferences/utility.js';
import { store } from '@/store.js';
import { waiting } from '@/os.js'; import { waiting } from '@/os.js';
import { unisonReload, reloadChannel } from '@/utility/unison-reload.js'; import { unisonReload } from '@/utility/unison-reload.js';
import { clear } from '@/utility/idb-proxy.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
export async function signout() { export async function signout() {
if (!$i) return; if (!$i) return;
// TODO: preferの自動バックアップがオンの場合、いろいろ消す前に強制バックアップ
waiting(); waiting();
localStorage.clear(); if (store.s.enablePreferencesAutoCloudBackup) {
defaultMemoryStorage.clear(); await cloudBackup();
}
const idbPromises = ['MisskeyClient', 'keyval-store'].map((name, i, arr) => new Promise<void>((res, rej) => { localStorage.clear();
const idbAbortController = new AbortController();
const timeout = window.setTimeout(() => idbAbortController.abort(), 5000);
const idbPromises = ['MisskeyClient'].map((name, i, arr) => new Promise<void>((res, rej) => {
const delidb = indexedDB.deleteDatabase(name); const delidb = indexedDB.deleteDatabase(name);
delidb.onsuccess = () => res(); delidb.onsuccess = () => res();
delidb.onerror = e => rej(e); delidb.onerror = e => rej(e);
delidb.onblocked = () => idbAbortController.signal.aborted && rej(new Error('Operation aborted'));
})); }));
await Promise.all(idbPromises); try {
await Promise.race([
Promise.all([
...idbPromises,
// idb keyval-storeはidb-keyvalライブラリによる別管理
clear(),
]),
new Promise((_, rej) => idbAbortController.signal.addEventListener('abort', () => rej(new Error('Operation timed out')))),
]);
} catch {
// nothing
} finally {
window.clearTimeout(timeout);
}
//#region Remove service worker registration //#region Remove service worker registration
try { try {
@ -50,7 +70,9 @@ export async function signout() {
.then(registrations => { .then(registrations => {
return Promise.all(registrations.map(registration => registration.unregister())); return Promise.all(registrations.map(registration => registration.unregister()));
}); });
} catch (err) {} } catch {
// nothing
}
//#endregion //#endregion
unisonReload('/'); unisonReload('/');

View File

@ -108,6 +108,10 @@ export const store = markRaw(new Pizzax('base', {
where: 'device', where: 'device',
default: {} as Record<string, string>, // host/userId, token default: {} as Record<string, string>, // host/userId, token
}, },
accountInfos: {
where: 'device',
default: {} as Record<string, Misskey.entities.User>, // host/userId, user
},
enablePreferencesAutoCloudBackup: { enablePreferencesAutoCloudBackup: {
where: 'device', where: 'device',

View File

@ -164,7 +164,6 @@ rt {
.ti { .ti {
width: 1.28em; width: 1.28em;
vertical-align: -12%; vertical-align: -12%;
line-height: 1em;
&::before { &::before {
font-size: 128%; font-size: 128%;

View File

@ -77,14 +77,17 @@ watch(rootEl, () => {
<style lang="scss" module> <style lang="scss" module>
.root { .root {
position: relative;
z-index: 1;
padding: 12px 12px max(12px, env(safe-area-inset-bottom, 0px)) 12px; padding: 12px 12px max(12px, env(safe-area-inset-bottom, 0px)) 12px;
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr; grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
grid-gap: 8px; grid-gap: 8px;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
background: var(--MI_THEME-bg); background: var(--MI_THEME-navBg);
border-top: solid 0.5px var(--MI_THEME-divider); color: var(--MI_THEME-navFg);
box-shadow: 0px 0px 6px 6px #0000000f;
} }
.item { .item {
@ -109,19 +112,17 @@ watch(rootEl, () => {
padding: 0; padding: 0;
aspect-ratio: 1; aspect-ratio: 1;
width: 100%; width: 100%;
max-width: 50px; max-width: 45px;
margin: auto; margin: auto;
align-content: center; align-content: center;
border-radius: 100%; border-radius: 100%;
background: var(--MI_THEME-panel);
color: var(--MI_THEME-fg);
&:hover { &:hover {
background: var(--MI_THEME-panelHighlight); background: var(--MI_THEME-panelHighlight);
} }
&:active { &:active {
background: hsl(from var(--MI_THEME-panel) h s calc(l - 2)); background: var(--MI_THEME-panelHighlight);
} }
} }
@ -131,14 +132,16 @@ watch(rootEl, () => {
.itemIndicator { .itemIndicator {
position: absolute; position: absolute;
top: 0; bottom: -4px;
left: 0; left: 0;
right: 0;
color: var(--MI_THEME-indicator); color: var(--MI_THEME-indicator);
font-size: 16px; font-size: 10px;
pointer-events: none;
&:has(.itemIndicateValueIcon) { &:has(.itemIndicateValueIcon) {
animation: none; animation: none;
font-size: 12px; font-size: 8px;
} }
} }
</style> </style>

View File

@ -97,6 +97,7 @@ import XWidgetsColumn from '@/ui/deck/widgets-column.vue';
import XMentionsColumn from '@/ui/deck/mentions-column.vue'; import XMentionsColumn from '@/ui/deck/mentions-column.vue';
import XDirectColumn from '@/ui/deck/direct-column.vue'; import XDirectColumn from '@/ui/deck/direct-column.vue';
import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue'; import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue';
import XChatColumn from '@/ui/deck/chat-column.vue';
import { mainRouter } from '@/router.js'; import { mainRouter } from '@/router.js';
import { columns, layout, columnTypes, switchProfileMenu, addColumn as addColumnToStore, deleteProfile as deleteProfile_ } from '@/deck.js'; import { columns, layout, columnTypes, switchProfileMenu, addColumn as addColumnToStore, deleteProfile as deleteProfile_ } from '@/deck.js';
@ -114,6 +115,7 @@ const columnComponents = {
mentions: XMentionsColumn, mentions: XMentionsColumn,
direct: XDirectColumn, direct: XDirectColumn,
roleTimeline: XRoleTimelineColumn, roleTimeline: XRoleTimelineColumn,
chat: XChatColumn,
}; };
mainRouter.navHook = (path, flag): boolean => { mainRouter.navHook = (path, flag): boolean => {

View File

@ -0,0 +1,27 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<XColumn :column="column" :isStacked="isStacked">
<template #header><i class="ti ti-messages" style="margin-right: 8px;"></i>{{ column.name || i18n.ts._deck._columns.chat }}</template>
<div style="padding: 8px;">
<MkChatHistories/>
</div>
</XColumn>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { i18n } from '../../i18n.js';
import XColumn from './column.vue';
import type { Column } from '@/deck.js';
import MkChatHistories from '@/components/MkChatHistories.vue';
defineProps<{
column: Column;
isStacked: boolean;
}>();
</script>

View File

@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { throttle } from 'throttle-debounce';
import { nextTick, onActivated, onDeactivated, onUnmounted, watch } from 'vue';
import type { Ref } from 'vue';
// note render skippingがオンだとズレるため、遷移直前にスクロール範囲に表示されているdata-scroll-anchor要素を特定して、復元時に当該要素までスクロールするようにする
// TODO: data-scroll-anchor がひとつも存在しない場合、または手動で useAnchor みたいなフラグをfalseで呼ばれた場合、単純にスクロール位置を使用する処理にフォールバックするようにする
export function useScrollPositionKeeper(scrollContainerRef: Ref<HTMLElement | null | undefined>): void {
let anchorId: string | null = null;
let ready = true;
watch(scrollContainerRef, (el) => {
if (!el) return;
const onScroll = () => {
if (!el) return;
if (!ready) return;
const scrollContainerRect = el.getBoundingClientRect();
const viewPosition = scrollContainerRect.height / 2;
const anchorEls = el.querySelectorAll('[data-scroll-anchor]');
for (let i = anchorEls.length - 1; i > -1; i--) { // 下から見た方が速い
const anchorEl = anchorEls[i] as HTMLElement;
const anchorRect = anchorEl.getBoundingClientRect();
const anchorTop = anchorRect.top;
const anchorBottom = anchorRect.bottom;
if (anchorTop <= viewPosition && anchorBottom >= viewPosition) {
anchorId = anchorEl.getAttribute('data-scroll-anchor');
break;
}
}
};
// ほんとはscrollイベントじゃなくてonBeforeDeactivatedでやりたい
// https://github.com/vuejs/vue/issues/9454
// https://github.com/vuejs/rfcs/pull/284
el.addEventListener('scroll', throttle(1000, onScroll), { passive: true });
}, {
immediate: true,
});
const restore = () => {
if (!anchorId) return;
const scrollContainer = scrollContainerRef.value;
if (!scrollContainer) return;
const scrollAnchorEl = scrollContainer.querySelector(`[data-scroll-anchor="${anchorId}"]`);
if (!scrollAnchorEl) return;
scrollAnchorEl.scrollIntoView({
behavior: 'instant',
block: 'center',
inline: 'center',
});
};
onDeactivated(() => {
ready = false;
});
onActivated(() => {
restore();
nextTick(() => {
restore();
window.setTimeout(() => {
restore();
ready = true;
}, 100);
});
});
}

View File

@ -7,6 +7,7 @@ import { nextTick, ref, defineAsyncComponent } from 'vue';
import getCaretCoordinates from 'textarea-caret'; import getCaretCoordinates from 'textarea-caret';
import { toASCII } from 'punycode.js'; import { toASCII } from 'punycode.js';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
import type { CompleteInfo } from '@/components/MkAutocomplete.vue';
import { popup } from '@/os.js'; import { popup } from '@/os.js';
export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam'; export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam';
@ -19,7 +20,7 @@ export class Autocomplete {
close: () => void; close: () => void;
} | null; } | null;
private textarea: HTMLInputElement | HTMLTextAreaElement; private textarea: HTMLInputElement | HTMLTextAreaElement;
private currentType: string; private currentType: keyof CompleteInfo | undefined;
private textRef: Ref<string | number | null>; private textRef: Ref<string | number | null>;
private opening: boolean; private opening: boolean;
private onlyType: SuggestionType[]; private onlyType: SuggestionType[];
@ -74,7 +75,7 @@ export class Autocomplete {
* *
*/ */
private onInput() { private onInput() {
const caretPos = this.textarea.selectionStart; const caretPos = Number(this.textarea.selectionStart);
const text = this.text.substring(0, caretPos).split('\n').pop()!; const text = this.text.substring(0, caretPos).split('\n').pop()!;
const mentionIndex = text.lastIndexOf('@'); const mentionIndex = text.lastIndexOf('@');
@ -101,6 +102,8 @@ export class Autocomplete {
const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' '); const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' ');
const isMfmTag = mfmTagIndex !== -1 && !isMfmParam; const isMfmTag = mfmTagIndex !== -1 && !isMfmParam;
const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':'); const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');
// :ok:などを🆗にするたいおぷ
const isEmojiCompleteToUnicode = !isEmoji && emojiIndex === text.length - 1;
let opened = false; let opened = false;
@ -137,6 +140,14 @@ export class Autocomplete {
} }
} }
if (isEmojiCompleteToUnicode && !opened && this.onlyType.includes('emoji')) {
const emoji = text.substring(text.lastIndexOf(':', text.length - 2) + 1, text.length - 1);
if (!emoji.includes(' ')) {
this.open('emojiComplete', emoji);
opened = true;
}
}
if (isMfmTag && !opened && this.onlyType.includes('mfmTag')) { if (isMfmTag && !opened && this.onlyType.includes('mfmTag')) {
const mfmTag = text.substring(mfmTagIndex + 1); const mfmTag = text.substring(mfmTagIndex + 1);
if (!mfmTag.includes(' ')) { if (!mfmTag.includes(' ')) {
@ -164,7 +175,7 @@ export class Autocomplete {
/** /**
* *
*/ */
private async open(type: string, q: any) { private async open<T extends keyof CompleteInfo>(type: T, q: CompleteInfo[T]['query']) {
if (type !== this.currentType) { if (type !== this.currentType) {
this.close(); this.close();
} }
@ -231,10 +242,10 @@ export class Autocomplete {
/** /**
* *
*/ */
private complete({ type, value }) { private complete<T extends keyof CompleteInfo>({ type, value }: { type: T; value: CompleteInfo[T]['payload'] }) {
this.close(); this.close();
const caret = this.textarea.selectionStart; const caret = Number(this.textarea.selectionStart);
if (type === 'user') { if (type === 'user') {
const source = this.text; const source = this.text;
@ -280,6 +291,22 @@ export class Autocomplete {
// 挿入 // 挿入
this.text = trimmedBefore + value + after; this.text = trimmedBefore + value + after;
// キャレットを戻す
nextTick(() => {
this.textarea.focus();
const pos = trimmedBefore.length + value.length;
this.textarea.setSelectionRange(pos, pos);
});
} else if (type === 'emojiComplete') {
const source = this.text;
const before = source.substring(0, caret);
const trimmedBefore = before.substring(0, before.lastIndexOf(':', before.length - 2));
const after = source.substring(caret);
// 挿入
this.text = trimmedBefore + value + after;
// キャレットを戻す // キャレットを戻す
nextTick(() => { nextTick(() => {
this.textarea.focus(); this.textarea.focus();

View File

@ -9,6 +9,7 @@ import {
get as iget, get as iget,
set as iset, set as iset,
del as idel, del as idel,
clear as iclear,
} from 'idb-keyval'; } from 'idb-keyval';
import { miLocalStorage } from '@/local-storage.js'; import { miLocalStorage } from '@/local-storage.js';
@ -51,3 +52,7 @@ export async function del(key: string) {
if (idbAvailable) return idel(key); if (idbAvailable) return idel(key);
return miLocalStorage.removeItem(`${PREFIX}${key}`); return miLocalStorage.removeItem(`${PREFIX}${key}`);
} }
export async function clear() {
if (idbAvailable) return iclear();
}

View File

@ -104,3 +104,33 @@ export function searchEmoji(query: string | null, emojiDb: EmojiDef[], max = 30)
.slice(0, max) .slice(0, max)
.map(it => it.emoji); .map(it => it.emoji);
} }
export function searchEmojiExact(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] {
if (!query) {
return [];
}
const matched = new Map<string, EmojiScore>();
// 完全一致(エイリアスなし)
emojiDb.some(x => {
if (x.name === query && !x.aliasOf) {
matched.set(x.name, { emoji: x, score: query.length + 3 });
}
return matched.size === max;
});
// 完全一致(エイリアス込み)
if (matched.size < max) {
emojiDb.some(x => {
if (x.name === query && !matched.has(x.aliasOf ?? x.name)) {
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length + 2 });
}
return matched.size === max;
});
}
return [...matched.values()]
.sort((x, y) => y.score - x.score)
.slice(0, max)
.map(it => it.emoji);
}

View File

@ -18,5 +18,5 @@ if (isTouchSupported && !isTouchUsing) {
}, { passive: true }); }, { passive: true });
} }
/** (MkHorizontalSwipe) 横スワイプ中か? */ /** (MkSwiper) 横スワイプ中か? */
export const isHorizontalSwipeSwiping = ref(false); export const isHorizontalSwipeSwiping = ref(false);

View File

@ -0,0 +1,52 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkContainer :showHeader="widgetProps.showHeader" class="mkw-chat">
<template #icon><i class="ti ti-users"></i></template>
<template #header>{{ i18n.ts._widgets.chat }}</template>
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configure()"><i class="ti ti-settings"></i></button></template>
<div>
<MkChatHistories/>
</div>
</MkContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
import { useWidgetPropsManager } from './widget.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { GetFormResultType } from '@/utility/form.js';
import MkContainer from '@/components/MkContainer.vue';
import { i18n } from '@/i18n.js';
import MkChatHistories from '@/components/MkChatHistories.vue';
const name = 'chat';
const widgetPropsDef = {
showHeader: {
type: 'boolean' as const,
default: true,
},
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
const props = defineProps<WidgetComponentProps<WidgetProps>>();
const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure, save } = useWidgetPropsManager(name,
widgetPropsDef,
props,
emit,
);
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>

View File

@ -35,6 +35,7 @@ export default function(app: App) {
app.component('WidgetUserList', defineAsyncComponent(() => import('./WidgetUserList.vue'))); app.component('WidgetUserList', defineAsyncComponent(() => import('./WidgetUserList.vue')));
app.component('WidgetClicker', defineAsyncComponent(() => import('./WidgetClicker.vue'))); app.component('WidgetClicker', defineAsyncComponent(() => import('./WidgetClicker.vue')));
app.component('WidgetBirthdayFollowings', defineAsyncComponent(() => import('./WidgetBirthdayFollowings.vue'))); app.component('WidgetBirthdayFollowings', defineAsyncComponent(() => import('./WidgetBirthdayFollowings.vue')));
app.component('WidgetChat', defineAsyncComponent(() => import('./WidgetChat.vue')));
} }
// 連合関連のウィジェット(連合無効時に隠す) // 連合関連のウィジェット(連合無効時に隠す)
@ -70,6 +71,7 @@ export const widgets = [
'userList', 'userList',
'clicker', 'clicker',
'birthdayFollowings', 'birthdayFollowings',
'chat',
...federationWidgets, ...federationWidgets,
]; ];

View File

@ -1,3 +0,0 @@
import { defineConfig } from 'vite';
export default defineConfig({});

View File

@ -24,13 +24,13 @@
"devDependencies": { "devDependencies": {
"@types/matter-js": "0.19.8", "@types/matter-js": "0.19.8",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@types/node": "22.13.11", "@types/node": "22.14.0",
"@typescript-eslint/eslint-plugin": "8.27.0", "@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.27.0", "@typescript-eslint/parser": "8.29.1",
"nodemon": "3.1.9", "nodemon": "3.1.9",
"execa": "9.5.2", "execa": "9.5.2",
"typescript": "5.8.2", "typescript": "5.8.3",
"esbuild": "0.25.1", "esbuild": "0.25.2",
"glob": "11.0.1" "glob": "11.0.1"
}, },
"files": [ "files": [

Some files were not shown because too many files have changed in this diff Show More