Merge branch 'develop' into future-2024-04-25-post

This commit is contained in:
dakkar 2024-05-11 13:11:07 +01:00
commit 30bd7768d6
70 changed files with 305 additions and 192 deletions

View File

@ -4,7 +4,7 @@ stages:
testCommit: testCommit:
stage: test stage: test
image: node:latest image: node:iron
services: services:
- postgres:15 - postgres:15
- redis - redis

View File

@ -1264,10 +1264,10 @@ _initialTutorial:
_reaction: _reaction:
title: "Què són les Reaccions?" title: "Què són les Reaccions?"
description: "Es poden reaccionar a les Notes amb diferents emoticones. Les reaccions et permeten expressar matisos que hi són més enllà d'un simple m'agrada." description: "Es poden reaccionar a les Notes amb diferents emoticones. Les reaccions et permeten expressar matisos que hi són més enllà d'un simple m'agrada."
letsTryReacting: "Es poden afegir reaccions fent clic al botó '+'. Prova reaccionant a aquesta nota!" letsTryReacting: "Es poden afegir reaccions fent clic al botó '{reaction}'. Prova reaccionant a aquesta nota!"
reactToContinue: "Afegeix una reacció per continuar." reactToContinue: "Afegeix una reacció per continuar."
reactNotification: "Rebràs notificacions en temps real quan un usuari reaccioni a les teves notes." reactNotification: "Rebràs notificacions en temps real quan un usuari reaccioni a les teves notes."
reactDone: "Pots desfer una reacció fent clic al botó '-'." reactDone: "Pots desfer una reacció fent clic al botó '{undo}'."
_timeline: _timeline:
title: "El concepte de les línies de temps" title: "El concepte de les línies de temps"
description1: "Misskey mostra diferents línies de temps basades en l'ús (algunes poden no estar disponibles depenent de la política del servidor)" description1: "Misskey mostra diferents línies de temps basades en l'ús (algunes poden no estar disponibles depenent de la política del servidor)"
@ -2255,4 +2255,3 @@ _externalResourceInstaller:
title: "Paràmetres no vàlids " title: "Paràmetres no vàlids "
_reversi: _reversi:
total: "Total" total: "Total"

View File

@ -1335,10 +1335,10 @@ _initialTutorial:
_reaction: _reaction:
title: "What are Reactions?" title: "What are Reactions?"
description: "Notes can be reacted to with various emojis. Reactions allow you to express nuances that may not be conveyed with just a 'like.'" description: "Notes can be reacted to with various emojis. Reactions allow you to express nuances that may not be conveyed with just a 'like.'"
letsTryReacting: "Reactions can be added by clicking the '+' button on the note. Try reacting to this sample note!" letsTryReacting: "Reactions can be added by clicking the '{reaction}' button on the note. Try reacting to this sample note!"
reactToContinue: "Add a reaction to proceed." reactToContinue: "Add a reaction to proceed."
reactNotification: "You'll receive real-time notifications when someone reacts to your note." reactNotification: "You'll receive real-time notifications when someone reacts to your note."
reactDone: "You can undo a reaction by pressing the '-' button." reactDone: "You can undo a reaction by pressing the '{undo}' button."
_timeline: _timeline:
title: "The Concept of Timelines" title: "The Concept of Timelines"
description1: "Sharkey provides multiple timelines based on usage (some may not be available depending on the server's policies)." description1: "Sharkey provides multiple timelines based on usage (some may not be available depending on the server's policies)."

View File

@ -1263,10 +1263,10 @@ _initialTutorial:
_reaction: _reaction:
title: "¿Qué son las reacciones?" title: "¿Qué son las reacciones?"
description: "Se puede reaccionar a las Notas con diferentes emojis. Las reacciones te permiten expresar matices que no se pueden transmitir con un simple 'me gusta'." description: "Se puede reaccionar a las Notas con diferentes emojis. Las reacciones te permiten expresar matices que no se pueden transmitir con un simple 'me gusta'."
letsTryReacting: "Puedes añadir reacciones pulsando en el botón '+' de la nota. ¡Intenta reaccionar a esta nota de ejemplo!" letsTryReacting: "Puedes añadir reacciones pulsando en el botón '{reaction}' de la nota. ¡Intenta reaccionar a esta nota de ejemplo!"
reactToContinue: "Añade una reacción para continuar." reactToContinue: "Añade una reacción para continuar."
reactNotification: "Recibirás notificaciones en tiempo real cuando alguien reaccione a tu nota." reactNotification: "Recibirás notificaciones en tiempo real cuando alguien reaccione a tu nota."
reactDone: "Puedes deshacer una reacción pulsando en el botón '-'." reactDone: "Puedes deshacer una reacción pulsando en el botón '{undo}'."
_timeline: _timeline:
title: "El concepto de Línea de tiempo" title: "El concepto de Línea de tiempo"
description1: "Misskey proporciona múltiples líneas de tiempo basadas en su uso (algunas pueden no estar disponibles dependiendo de las políticas de la instancia)." description1: "Misskey proporciona múltiples líneas de tiempo basadas en su uso (algunas pueden no estar disponibles dependiendo de las políticas de la instancia)."
@ -2449,4 +2449,3 @@ _reversi:
reversi: "Reversi" reversi: "Reversi"
won: "{name} ha ganado" won: "{name} ha ganado"
total: "Total" total: "Total"

View File

@ -1245,10 +1245,10 @@ _initialTutorial:
_reaction: _reaction:
title: "Qu'est-ce que les réactions ?" title: "Qu'est-ce que les réactions ?"
description: "Vous pouvez ajouter des « réactions » aux notes. Les réactions vous permettent d'exprimer à l'aise des nuances qui ne peuvent pas être exprimées par des mentions j'aime." description: "Vous pouvez ajouter des « réactions » aux notes. Les réactions vous permettent d'exprimer à l'aise des nuances qui ne peuvent pas être exprimées par des mentions j'aime."
letsTryReacting: "Des réactions peuvent être ajoutées en cliquant sur le bouton « + » de la note. Essayez d'ajouter une réaction à cet exemple de note !" letsTryReacting: "Des réactions peuvent être ajoutées en cliquant sur le bouton « {reaction} » de la note. Essayez d'ajouter une réaction à cet exemple de note !"
reactToContinue: "Ajoutez une réaction pour procéder." reactToContinue: "Ajoutez une réaction pour procéder."
reactNotification: "Vous recevez des notifications en temps réel lorsque quelqu'un réagit à votre note." reactNotification: "Vous recevez des notifications en temps réel lorsque quelqu'un réagit à votre note."
reactDone: "Vous pouvez annuler la réaction en cliquant sur le bouton « - » ." reactDone: "Vous pouvez annuler la réaction en cliquant sur le bouton « {undo} » ."
_timeline: _timeline:
title: "Fonctionnement des fils" title: "Fonctionnement des fils"
description1: "Misskey offre plusieurs fils selon l'usage (certains peuvent être désactivés par le serveur)." description1: "Misskey offre plusieurs fils selon l'usage (certains peuvent être désactivés par le serveur)."
@ -2140,4 +2140,3 @@ _dataSaver:
description: "Si la notation de mise en évidence du code est utilisée, par exemple dans la MFM, elle ne sera pas chargée tant qu'elle n'aura pas été tapée. La mise en évidence du code nécessite le chargement du fichier de définition de chaque langue à mettre en évidence, mais comme ces fichiers ne sont plus chargés automatiquement, on peut s'attendre à une réduction du trafic de données." description: "Si la notation de mise en évidence du code est utilisée, par exemple dans la MFM, elle ne sera pas chargée tant qu'elle n'aura pas été tapée. La mise en évidence du code nécessite le chargement du fichier de définition de chaque langue à mettre en évidence, mais comme ces fichiers ne sont plus chargés automatiquement, on peut s'attendre à une réduction du trafic de données."
_reversi: _reversi:
total: "Total" total: "Total"

8
locales/index.d.ts vendored
View File

@ -5378,9 +5378,9 @@ export interface Locale extends ILocale {
*/ */
"description": string; "description": string;
/** /**
* * {reaction}
*/ */
"letsTryReacting": string; "letsTryReacting": ParameterizedString<"reaction">;
/** /**
* *
*/ */
@ -5390,9 +5390,9 @@ export interface Locale extends ILocale {
*/ */
"reactNotification": string; "reactNotification": string;
/** /**
* * {undo}
*/ */
"reactDone": string; "reactDone": ParameterizedString<"undo">;
}; };
"_timeline": { "_timeline": {
/** /**

View File

@ -1275,10 +1275,10 @@ _initialTutorial:
_reaction: _reaction:
title: "Cosa sono le Reazioni?" title: "Cosa sono le Reazioni?"
description: "Puoi reagire alle Note. Le sensazioni che non si riescono a trasmettere con i \"Mi piace\" si possono esprimere facilmente inviando una reazione." description: "Puoi reagire alle Note. Le sensazioni che non si riescono a trasmettere con i \"Mi piace\" si possono esprimere facilmente inviando una reazione."
letsTryReacting: "Puoi aggiungere una Reazione cliccando il bottone \"+\" (più) della relativa Nota. Prova ad aggiungerne una a questa Nota di esempio!" letsTryReacting: "Puoi aggiungere una Reazione cliccando il bottone \"{reaction}\" della relativa Nota. Prova ad aggiungerne una a questa Nota di esempio!"
reactToContinue: "Aggiungere la Reazione ti consentirà di procedere col tutorial." reactToContinue: "Aggiungere la Reazione ti consentirà di procedere col tutorial."
reactNotification: "Quando qualcuno reagisce alle tue Note, ricevi una notifica in tempo reale." reactNotification: "Quando qualcuno reagisce alle tue Note, ricevi una notifica in tempo reale."
reactDone: "Puoi annullare la tua Reazione premendo il bottone \"ー\" (meno)" reactDone: "Puoi annullare la tua Reazione premendo il bottone \"{undo}\""
_timeline: _timeline:
title: "Come funziona la Timeline" title: "Come funziona la Timeline"
description1: "Misskey fornisce alcune Timeline (sequenze cronologiche di Note). Una di queste potrebbe essere stata disattivata dagli amministratori." description1: "Misskey fornisce alcune Timeline (sequenze cronologiche di Note). Una di queste potrebbe essere stata disattivata dagli amministratori."
@ -2509,4 +2509,3 @@ _reversi:
_offlineScreen: _offlineScreen:
title: "Scollegato. Impossibile connettersi al server" title: "Scollegato. Impossibile connettersi al server"
header: "Impossibile connettersi al server" header: "Impossibile connettersi al server"

View File

@ -1349,10 +1349,10 @@ _initialTutorial:
_reaction: _reaction:
title: "リアクションって何?" title: "リアクションって何?"
description: "ノートには「リアクション」をつけることができます。「いいね」では伝わらないニュアンスも、リアクションで簡単・気軽に表現できます。" description: "ノートには「リアクション」をつけることができます。「いいね」では伝わらないニュアンスも、リアクションで簡単・気軽に表現できます。"
letsTryReacting: "リアクションは、ノートの「」ボタンをクリックするとつけられます。試しにこのサンプルのノートにリアクションをつけてみてください!" letsTryReacting: "リアクションは、ノートの「{reaction}」ボタンをクリックするとつけられます。試しにこのサンプルのノートにリアクションをつけてみてください!"
reactToContinue: "リアクションをつけると先に進めるようになります。" reactToContinue: "リアクションをつけると先に進めるようになります。"
reactNotification: "あなたのノートが誰かにリアクションされると、リアルタイムで通知を受け取ります。" reactNotification: "あなたのノートが誰かにリアクションされると、リアルタイムで通知を受け取ります。"
reactDone: "「」ボタンを押すとリアクションを取り消すことができます。" reactDone: "「{undo}」ボタンを押すとリアクションを取り消すことができます。"
_timeline: _timeline:
title: "タイムラインのしくみ" title: "タイムラインのしくみ"
description1: "Sharkeyには、使い方に応じて複数のタイムラインが用意されていますサーバーによってはいずれかが無効になっていることがあります。" description1: "Sharkeyには、使い方に応じて複数のタイムラインが用意されていますサーバーによってはいずれかが無効になっていることがあります。"

View File

@ -1265,10 +1265,10 @@ _initialTutorial:
_reaction: _reaction:
title: "ツッコミってなんや?" title: "ツッコミってなんや?"
description: "ノートには「ツッコミ」できんねん。「いいね」とか何言っとるかわからんし、簡単に表現できるのはええことやん?" description: "ノートには「ツッコミ」できんねん。「いいね」とか何言っとるかわからんし、簡単に表現できるのはええことやん?"
letsTryReacting: "ノートの「」ボタンでツッコめるわ。試しに下のノートにツッコんでみ。" letsTryReacting: "ノートの「{reaction}」ボタンでツッコめるわ。試しに下のノートにツッコんでみ。"
reactToContinue: "ツッコんだら進めるようになるで。" reactToContinue: "ツッコんだら進めるようになるで。"
reactNotification: "あんたのノートが誰かにツッコまれたら、すぐ通知するで。" reactNotification: "あんたのノートが誰かにツッコまれたら、すぐ通知するで。"
reactDone: "「」ボタンでツッコミやめれるで。" reactDone: "「{undo}」ボタンでツッコミやめれるで。"
_timeline: _timeline:
title: "タイムラインのしくみ" title: "タイムラインのしくみ"
description1: "Sharkeyには、いろいろタイムラインがあんでただ、サーバーによっては無効化されてるところもあるな。" description1: "Sharkeyには、いろいろタイムラインがあんでただ、サーバーによっては無効化されてるところもあるな。"

View File

@ -1271,10 +1271,10 @@ _initialTutorial:
_reaction: _reaction:
title: "'리액션'이 무엇인가요?" title: "'리액션'이 무엇인가요?"
description: "노트에 '리액션'을 보낼 수 있습니다. '좋아요'만으로는 충분히 전해지지 않는 감정을, 이모지에 실어서 가볍게 보낼 수 있습니다." description: "노트에 '리액션'을 보낼 수 있습니다. '좋아요'만으로는 충분히 전해지지 않는 감정을, 이모지에 실어서 가볍게 보낼 수 있습니다."
letsTryReacting: "리액션은 노트의 '+' 버튼을 클릭하여 붙일 수 있습니다. 지금 표시되는 샘플 노트에 리액션을 달아 보세요!" letsTryReacting: "리액션은 노트의 '{reaction}' 버튼을 클릭하여 붙일 수 있습니다. 지금 표시되는 샘플 노트에 리액션을 달아 보세요!"
reactToContinue: "다음으로 진행하려면 리액션을 보내세요." reactToContinue: "다음으로 진행하려면 리액션을 보내세요."
reactNotification: "누군가가 나의 노트에 리액션을 보내면 실시간으로 알림을 받게 됩니다." reactNotification: "누군가가 나의 노트에 리액션을 보내면 실시간으로 알림을 받게 됩니다."
reactDone: "'-' 버튼을 눌러서 리액션을 취소할 수 있습니다." reactDone: "'{undo}' 버튼을 눌러서 리액션을 취소할 수 있습니다."
_timeline: _timeline:
title: "타임라인에 대하여" title: "타임라인에 대하여"
description1: "Misskey에는 종류에 따라 여러 가지의 타임라인으로 구성되어 있습니다.(서버에 따라서는 일부 타임라인을 사용할 수 없는 경우가 있습니다)" description1: "Misskey에는 종류에 따라 여러 가지의 타임라인으로 구성되어 있습니다.(서버에 따라서는 일부 타임라인을 사용할 수 없는 경우가 있습니다)"
@ -2505,4 +2505,3 @@ _reversi:
_offlineScreen: _offlineScreen:
title: "오프라인 - 서버에 접속할 수 없습니다" title: "오프라인 - 서버에 접속할 수 없습니다"
header: "서버에 접속할 수 없습니다" header: "서버에 접속할 수 없습니다"

View File

@ -73,7 +73,7 @@ exportRequested: "Zażądałeś eksportu. Może to zająć trochę czasu. Po zak
importRequested: "Zażądano importu. Może to zająć chwilę." importRequested: "Zażądano importu. Może to zająć chwilę."
lists: "Listy" lists: "Listy"
noLists: "Nie masz żadnych list" noLists: "Nie masz żadnych list"
note: "Utwórz wpis" note: "Wpis"
notes: "Wpisy" notes: "Wpisy"
following: "Obserwowani" following: "Obserwowani"
followers: "Obserwujący" followers: "Obserwujący"
@ -1400,4 +1400,3 @@ _moderationLogTypes:
resetPassword: "Zresetuj hasło" resetPassword: "Zresetuj hasło"
_reversi: _reversi:
total: "Łącznie" total: "Łącznie"

View File

@ -1285,10 +1285,10 @@ _initialTutorial:
_reaction: _reaction:
title: "รีแอคชั่นคืออะไร?" title: "รีแอคชั่นคืออะไร?"
description: "โน้ตสามารถ“รีแอคชั่น”ด้วยเอโมจิต่างๆ ซึ่งทำให้สามารถแสดงความแตกต่างเล็กๆ น้อยๆ ที่อาจไม่สามารถสื่อออกมาได้ด้วยการแค่การกด “ถูกใจ”" description: "โน้ตสามารถ“รีแอคชั่น”ด้วยเอโมจิต่างๆ ซึ่งทำให้สามารถแสดงความแตกต่างเล็กๆ น้อยๆ ที่อาจไม่สามารถสื่อออกมาได้ด้วยการแค่การกด “ถูกใจ”"
letsTryReacting: "คุณสามารถเพิ่มรีแอคชั่นได้ด้วยการคลิกปุ่ม “+” บนโน้ต ลองรีแอคชั่นโน้ตตัวอย่างนี้ดูสิ!" letsTryReacting: "คุณสามารถเพิ่มรีแอคชั่นได้ด้วยการคลิกปุ่ม “{reaction}” บนโน้ต ลองรีแอคชั่นโน้ตตัวอย่างนี้ดูสิ!"
reactToContinue: "เพิ่มรีแอคชั่นเพื่อดำเนินการต่อ" reactToContinue: "เพิ่มรีแอคชั่นเพื่อดำเนินการต่อ"
reactNotification: "คุณจะได้รับการแจ้งเตือนแบบเรียลไทม์เมื่อมีคนตอบรีแอคชั่นโน้ตของคุณ" reactNotification: "คุณจะได้รับการแจ้งเตือนแบบเรียลไทม์เมื่อมีคนตอบรีแอคชั่นโน้ตของคุณ"
reactDone: "คุณสามารถยกเลิกรีแอคชั่นได้โดยการกดปุ่ม “-”" reactDone: "คุณสามารถยกเลิกรีแอคชั่นได้โดยการกดปุ่ม “{undo}”"
_timeline: _timeline:
title: "แนวคิดเรื่องของไทม์ไลน์" title: "แนวคิดเรื่องของไทม์ไลน์"
description1: "Misskey มีหลายไทม์ไลน์ขึ้นอยู่กับวิธีการใช้งานของคุณ (บางไทม์ไลน์อาจไม่สามารถใช้ได้ขึ้นอยู่กับนโยบายของเซิร์ฟเวอร์)" description1: "Misskey มีหลายไทม์ไลน์ขึ้นอยู่กับวิธีการใช้งานของคุณ (บางไทม์ไลน์อาจไม่สามารถใช้ได้ขึ้นอยู่กับนโยบายของเซิร์ฟเวอร์)"
@ -2524,4 +2524,3 @@ _reversi:
_offlineScreen: _offlineScreen:
title: "ออฟไลน์ - ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้" title: "ออฟไลน์ - ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้"
header: "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้" header: "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ได้"

View File

@ -1284,10 +1284,10 @@ _initialTutorial:
_reaction: _reaction:
title: "什么是回应?" title: "什么是回应?"
description: "您可以在帖子中添加“回应”。 您可以使用反应轻松地表达点“赞”所无法传达的细微差别。" description: "您可以在帖子中添加“回应”。 您可以使用反应轻松地表达点“赞”所无法传达的细微差别。"
letsTryReacting: "回应可以通过点击帖子中的「+」按钮来添加。试着给这个示例帖子添加一个回应!" letsTryReacting: "回应可以通过点击帖子中的「{reaction}」按钮来添加。试着给这个示例帖子添加一个回应!"
reactToContinue: "添加一个回应来继续" reactToContinue: "添加一个回应来继续"
reactNotification: "当您的帖子被某人添加了回应时,将实时收到通知。" reactNotification: "当您的帖子被某人添加了回应时,将实时收到通知。"
reactDone: "通过按下「」按钮,可以取消已经添加的回应" reactDone: "通过按下「{undo}」按钮,可以取消已经添加的回应"
_timeline: _timeline:
title: "时间线的运作方式" title: "时间线的运作方式"
description1: "Misskey 根据使用方式提供了多个时间线(根据服务器的设定,可能有一些被禁用)。" description1: "Misskey 根据使用方式提供了多个时间线(根据服务器的设定,可能有一些被禁用)。"
@ -2519,4 +2519,3 @@ _reversi:
_offlineScreen: _offlineScreen:
title: "离线——无法连接到服务器" title: "离线——无法连接到服务器"
header: "无法连接到服务器" header: "无法连接到服务器"

View File

@ -1285,10 +1285,10 @@ _initialTutorial:
_reaction: _reaction:
title: "什麼是反應?" title: "什麼是反應?"
description: "您可以在貼文中添加「反應」。您可以使用反應輕鬆隨意地表達「最愛/大心」所無法傳達的細微差別。" description: "您可以在貼文中添加「反應」。您可以使用反應輕鬆隨意地表達「最愛/大心」所無法傳達的細微差別。"
letsTryReacting: "可以透過點擊貼文上的「+」按鈕來添加反應。請嘗試在此範例貼文添加反應!" letsTryReacting: "可以透過點擊貼文上的「{reaction}」按鈕來添加反應。請嘗試在此範例貼文添加反應!"
reactToContinue: "添加反應以繼續教學課程。" reactToContinue: "添加反應以繼續教學課程。"
reactNotification: "當有人對您的貼文做出反應時會即時接收到通知。" reactNotification: "當有人對您的貼文做出反應時會即時接收到通知。"
reactDone: "按下「-」按鈕可以取消反應。" reactDone: "按下「{undo}」按鈕可以取消反應。"
_timeline: _timeline:
title: "時間軸如何運作" title: "時間軸如何運作"
description1: "Misskey根據使用方式提供了多個時間軸伺服器可能會將部份時間軸停用。" description1: "Misskey根據使用方式提供了多個時間軸伺服器可能會將部份時間軸停用。"
@ -2524,4 +2524,3 @@ _reversi:
_offlineScreen: _offlineScreen:
title: "離線-無法連接伺服器" title: "離線-無法連接伺服器"
header: "無法連接伺服器" header: "無法連接伺服器"

View File

@ -1,6 +1,6 @@
{ {
"name": "sharkey", "name": "sharkey",
"version": "2024.3.2-devel", "version": "2024.3.3-devel",
"codename": "shonk", "codename": "shonk",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -88,7 +88,7 @@
"@smithy/node-http-handler": "2.1.10", "@smithy/node-http-handler": "2.1.10",
"@swc/cli": "0.1.63", "@swc/cli": "0.1.63",
"@swc/core": "1.3.107", "@swc/core": "1.3.107",
"@transfem-org/sfm-js": "0.24.4", "@transfem-org/sfm-js": "0.24.5",
"@twemoji/parser": "15.0.0", "@twemoji/parser": "15.0.0",
"accepts": "1.3.8", "accepts": "1.3.8",
"ajv": "8.12.0", "ajv": "8.12.0",

View File

@ -430,11 +430,16 @@ export class NoteEditService implements OnApplicationShutdown {
update.hasPoll = !!data.poll; update.hasPoll = !!data.poll;
} }
// technically we should check if the two sets of files are
// different, or if their descriptions have changed. In practice
// this is good enough.
const filesChanged = oldnote.fileIds?.length || data.files?.length;
const poll = await this.pollsRepository.findOneBy({ noteId: oldnote.id }); const poll = await this.pollsRepository.findOneBy({ noteId: oldnote.id });
const oldPoll = poll ? { choices: poll.choices, multiple: poll.multiple, expiresAt: poll.expiresAt } : null; const oldPoll = poll ? { choices: poll.choices, multiple: poll.multiple, expiresAt: poll.expiresAt } : null;
if (Object.keys(update).length > 0) { if (Object.keys(update).length > 0 || filesChanged) {
const exists = await this.noteEditRepository.findOneBy({ noteId: oldnote.id }); const exists = await this.noteEditRepository.findOneBy({ noteId: oldnote.id });
await this.noteEditRepository.insert({ await this.noteEditRepository.insert({

View File

@ -64,8 +64,8 @@ type DecodedReaction = {
host?: string | null; host?: string | null;
}; };
const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/; const isCustomEmojiRegexp = /^:([\p{Letter}\p{Number}\p{Mark}_+-]+)(?:@\.)?:$/u;
const decodeCustomEmojiRegexp = /^:([\w+-]+)(?:@([\w.-]+))?:$/; const decodeCustomEmojiRegexp = /^:([\p{Letter}\p{Number}\p{Mark}_+-]+)(?:@([\w.-]+))?:$/u;
@Injectable() @Injectable()
export class ReactionService { export class ReactionService {

View File

@ -31,6 +31,7 @@ import { IdService } from '@/core/IdService.js';
import { MetaService } from '../MetaService.js'; import { MetaService } from '../MetaService.js';
import { LdSignatureService } from './LdSignatureService.js'; import { LdSignatureService } from './LdSignatureService.js';
import { ApMfmService } from './ApMfmService.js'; import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js';
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js'; import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
@Injectable() @Injectable()
@ -283,9 +284,10 @@ export class ApRendererService {
if (instance && instance.softwareName === 'mastodon') isMastodon = true; if (instance && instance.softwareName === 'mastodon') isMastodon = true;
if (instance && instance.softwareName === 'akkoma') isMastodon = true; if (instance && instance.softwareName === 'akkoma') isMastodon = true;
if (instance && instance.softwareName === 'pleroma') isMastodon = true; if (instance && instance.softwareName === 'pleroma') isMastodon = true;
if (instance && instance.softwareName === 'iceshrimp.net') isMastodon = true;
} }
} }
const object: ILike = { const object: ILike = {
type: 'Like', type: 'Like',
id: `${this.config.url}/likes/${noteReaction.id}`, id: `${this.config.url}/likes/${noteReaction.id}`,
@ -785,48 +787,7 @@ export class ApRendererService {
x.id = `${this.config.url}/${randomUUID()}`; x.id = `${this.config.url}/${randomUUID()}`;
} }
return Object.assign({ return Object.assign({ '@context': CONTEXT }, x as T & { id: string });
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{
Key: 'sec:Key',
// as non-standards
manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
sensitive: 'as:sensitive',
Hashtag: 'as:Hashtag',
quoteUrl: 'as:quoteUrl',
fedibird: 'http://fedibird.com/ns#',
quoteUri: 'fedibird:quoteUri',
// Mastodon
toot: 'http://joinmastodon.org/ns#',
Emoji: 'toot:Emoji',
featured: 'toot:featured',
discoverable: 'toot:discoverable',
// schema
schema: 'http://schema.org#',
PropertyValue: 'schema:PropertyValue',
value: 'schema:value',
// Misskey
misskey: 'https://misskey-hub.net/ns#',
'_misskey_content': 'misskey:_misskey_content',
'_misskey_quote': 'misskey:_misskey_quote',
'_misskey_reaction': 'misskey:_misskey_reaction',
'_misskey_votes': 'misskey:_misskey_votes',
'_misskey_summary': 'misskey:_misskey_summary',
'isCat': 'misskey:isCat',
// Firefish
firefish: 'https://joinfirefish.org/ns#',
speakAsCat: 'firefish:speakAsCat',
// Sharkey
sharkey: 'https://joinsharkey.org/ns#',
backgroundUrl: 'sharkey:backgroundUrl',
listenbrainz: 'sharkey:listenbrainz',
// vcard
vcard: 'http://www.w3.org/2006/vcard/ns#',
},
],
}, x as T & { id: string });
} }
@bindThis @bindThis

View File

@ -7,7 +7,7 @@ import * as crypto from 'node:crypto';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { HttpRequestService } from '@/core/HttpRequestService.js'; import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { CONTEXTS } from './misc/contexts.js'; import { CONTEXT, CONTEXTS } from './misc/contexts.js';
import { validateContentTypeSetAsJsonLD } from './misc/validator.js'; import { validateContentTypeSetAsJsonLD } from './misc/validator.js';
import type { JsonLdDocument } from 'jsonld'; import type { JsonLdDocument } from 'jsonld';
import type { JsonLd, RemoteDocument } from 'jsonld/jsonld-spec.js'; import type { JsonLd, RemoteDocument } from 'jsonld/jsonld-spec.js';
@ -88,6 +88,16 @@ class LdSignature {
return verifyData; return verifyData;
} }
@bindThis
public async compact(data: any, context: any = CONTEXT): Promise<JsonLdDocument> {
const customLoader = this.getLoader();
// XXX: Importing jsonld dynamically since Jest frequently fails to import it statically
// https://github.com/misskey-dev/misskey/pull/9894#discussion_r1103753595
return (await import('jsonld')).default.compact(data, context, {
documentLoader: customLoader,
});
}
@bindThis @bindThis
public async normalize(data: JsonLdDocument): Promise<string> { public async normalize(data: JsonLdDocument): Promise<string> {
const customLoader = this.getLoader(); const customLoader = this.getLoader();

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { JsonLd } from 'jsonld/jsonld-spec.js'; import type { Context, JsonLd } from 'jsonld/jsonld-spec.js';
/* eslint:disable:quotemark indent */ /* eslint:disable:quotemark indent */
const id_v1 = { const id_v1 = {
@ -526,6 +526,50 @@ const activitystreams = {
}, },
} satisfies JsonLd; } satisfies JsonLd;
const context_iris = [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
];
const extension_context_definition = {
Key: 'sec:Key',
// as non-standards
manuallyApprovesFollowers: 'as:manuallyApprovesFollowers',
sensitive: 'as:sensitive',
Hashtag: 'as:Hashtag',
quoteUrl: 'as:quoteUrl',
fedibird: 'http://fedibird.com/ns#',
quoteUri: 'fedibird:quoteUri',
// Mastodon
toot: 'http://joinmastodon.org/ns#',
Emoji: 'toot:Emoji',
featured: 'toot:featured',
discoverable: 'toot:discoverable',
// schema
schema: 'http://schema.org#',
PropertyValue: 'schema:PropertyValue',
value: 'schema:value',
// Misskey
misskey: 'https://misskey-hub.net/ns#',
'_misskey_content': 'misskey:_misskey_content',
'_misskey_quote': 'misskey:_misskey_quote',
'_misskey_reaction': 'misskey:_misskey_reaction',
'_misskey_votes': 'misskey:_misskey_votes',
'_misskey_summary': 'misskey:_misskey_summary',
'isCat': 'misskey:isCat',
// Firefish
firefish: 'https://joinfirefish.org/ns#',
speakAsCat: 'firefish:speakAsCat',
// Sharkey
sharkey: 'https://joinsharkey.org/ns#',
backgroundUrl: 'sharkey:backgroundUrl',
listenbrainz: 'sharkey:listenbrainz',
// vcard
vcard: 'http://www.w3.org/2006/vcard/ns#',
} satisfies Context;
export const CONTEXT: (string | Context)[] = [...context_iris, extension_context_definition];
export const CONTEXTS: Record<string, JsonLd> = { export const CONTEXTS: Record<string, JsonLd> = {
'https://w3id.org/identity/v1': id_v1, 'https://w3id.org/identity/v1': id_v1,
'https://w3id.org/security/v1': security_v1, 'https://w3id.org/security/v1': security_v1,

View File

@ -4,7 +4,7 @@
*/ */
import { Injectable, Inject } from '@nestjs/common'; import { Injectable, Inject } from '@nestjs/common';
import { Not, IsNull, DataSource } from 'typeorm'; import { Not, IsNull, Like, DataSource } from 'typeorm';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import { AppLockService } from '@/core/AppLockService.js'; import { AppLockService } from '@/core/AppLockService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@ -37,7 +37,10 @@ export default class UsersChart extends Chart<typeof schema> { // eslint-disable
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> { protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {
const [localCount, remoteCount] = await Promise.all([ const [localCount, remoteCount] = await Promise.all([
this.usersRepository.countBy({ host: IsNull() }), // that Not(Like()) is ugly, but it matches the logic in
// packages/backend/src/models/User.ts to not count "system"
// accounts
this.usersRepository.countBy({ host: IsNull(), username: Not(Like('%.%')) }),
this.usersRepository.countBy({ host: Not(IsNull()) }), this.usersRepository.countBy({ host: Not(IsNull()) }),
]); ]);

View File

@ -33,6 +33,12 @@ export class CleanRemoteFilesProcessorService {
let deletedCount = 0; let deletedCount = 0;
let cursor: MiDriveFile['id'] | null = null; let cursor: MiDriveFile['id'] | null = null;
let errorCount = 0;
const total = await this.driveFilesRepository.countBy({
userHost: Not(IsNull()),
isLink: false,
});
while (true) { while (true) {
const files = await this.driveFilesRepository.find({ const files = await this.driveFilesRepository.find({
@ -41,7 +47,7 @@ export class CleanRemoteFilesProcessorService {
isLink: false, isLink: false,
...(cursor ? { id: MoreThan(cursor) } : {}), ...(cursor ? { id: MoreThan(cursor) } : {}),
}, },
take: 8, take: 256,
order: { order: {
id: 1, id: 1,
}, },
@ -54,18 +60,22 @@ export class CleanRemoteFilesProcessorService {
cursor = files.at(-1)?.id ?? null; cursor = files.at(-1)?.id ?? null;
await Promise.all(files.map(file => this.driveService.deleteFileSync(file, true))); // Handle deletion in a batch
const results = await Promise.allSettled(files.map(file => this.driveService.deleteFileSync(file, true)));
deletedCount += 8; results.forEach((result, index) => {
if (result.status === 'fulfilled') {
const total = await this.driveFilesRepository.countBy({ deletedCount++;
userHost: Not(IsNull()), } else {
isLink: false, this.logger.error(`Failed to delete file ID ${files[index].id}: ${result.reason}`);
errorCount++;
}
}); });
job.updateProgress(100 / total * deletedCount); await job.updateProgress(100 / total * deletedCount);
} }
this.logger.succ('All cached remote files has been deleted.'); this.logger.succ(`All cached remote files processed. Total deleted: ${deletedCount}, Failed: ${errorCount}.`);
} }
} }

View File

@ -85,7 +85,7 @@ export class ExportCustomEmojisProcessorService {
}); });
for (const emoji of customEmojis) { for (const emoji of customEmojis) {
if (!/^[a-zA-Z0-9_]+$/.test(emoji.name)) { if (!/^[\p{Letter}\p{Number}\p{Mark}_+-]+$/u.test(emoji.name)) {
this.logger.error(`invalid emoji name: ${emoji.name}`); this.logger.error(`invalid emoji name: ${emoji.name}`);
continue; continue;
} }

View File

@ -79,13 +79,14 @@ export class ImportCustomEmojisProcessorService {
continue; continue;
} }
const emojiInfo = record.emoji; const emojiInfo = record.emoji;
if (!/^[a-zA-Z0-9_]+$/.test(emojiInfo.name)) { const nameNfc = emojiInfo.name.normalize('NFC');
this.logger.error(`invalid emojiname: ${emojiInfo.name}`); if (!/^[\p{Letter}\p{Number}\p{Mark}_+-]+$/u.test(nameNfc)) {
this.logger.error(`invalid emojiname: ${nameNfc}`);
continue; continue;
} }
const emojiPath = outputPath + '/' + record.fileName; const emojiPath = outputPath + '/' + record.fileName;
await this.emojisRepository.delete({ await this.emojisRepository.delete({
name: emojiInfo.name, name: nameNfc,
}); });
const driveFile = await this.driveService.addFile({ const driveFile = await this.driveService.addFile({
user: null, user: null,
@ -94,10 +95,10 @@ export class ImportCustomEmojisProcessorService {
force: true, force: true,
}); });
await this.customEmojiService.add({ await this.customEmojiService.add({
name: emojiInfo.name, name: nameNfc,
category: emojiInfo.category, category: emojiInfo.category?.normalize('NFC'),
host: null, host: null,
aliases: emojiInfo.aliases, aliases: emojiInfo.aliases?.map((a: string) => a.normalize('NFC')),
driveFile, driveFile,
license: emojiInfo.license, license: emojiInfo.license,
isSensitive: emojiInfo.isSensitive, isSensitive: emojiInfo.isSensitive,

View File

@ -15,6 +15,7 @@ import InstanceChart from '@/core/chart/charts/instance.js';
import ApRequestChart from '@/core/chart/charts/ap-request.js'; import ApRequestChart from '@/core/chart/charts/ap-request.js';
import FederationChart from '@/core/chart/charts/federation.js'; import FederationChart from '@/core/chart/charts/federation.js';
import { getApId } from '@/core/activitypub/type.js'; import { getApId } from '@/core/activitypub/type.js';
import type { IActivity } from '@/core/activitypub/type.js';
import type { MiRemoteUser } from '@/models/User.js'; import type { MiRemoteUser } from '@/models/User.js';
import type { MiUserPublickey } from '@/models/UserPublickey.js'; import type { MiUserPublickey } from '@/models/UserPublickey.js';
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js'; import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
@ -52,7 +53,7 @@ export class InboxProcessorService {
@bindThis @bindThis
public async process(job: Bull.Job<InboxJobData>): Promise<string> { public async process(job: Bull.Job<InboxJobData>): Promise<string> {
const signature = job.data.signature; // HTTP-signature const signature = job.data.signature; // HTTP-signature
const activity = job.data.activity; let activity = job.data.activity;
//#region Log //#region Log
const info = Object.assign({}, activity); const info = Object.assign({}, activity);
@ -150,6 +151,17 @@ export class InboxProcessorService {
throw new Bull.UnrecoverableError('skip: LD-Signatureの検証に失敗しました'); throw new Bull.UnrecoverableError('skip: LD-Signatureの検証に失敗しました');
} }
// アクティビティを正規化
delete activity.signature;
try {
activity = await ldSignature.compact(activity) as IActivity;
} catch (e) {
throw new Bull.UnrecoverableError(`skip: failed to compact activity: ${e}`);
}
// TODO: 元のアクティビティと非互換な形に正規化される場合は転送をスキップする
// https://github.com/mastodon/mastodon/blob/664b0ca/app/services/activitypub/process_collection_service.rb#L24-L29
activity.signature = ldSignature;
// もう一度actorチェック // もう一度actorチェック
if (authUser.user.uri !== activity.actor) { if (authUser.user.uri !== activity.actor) {
throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`); throw new Bull.UnrecoverableError(`skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`);

View File

@ -34,7 +34,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private customEmojiService: CustomEmojiService, private customEmojiService: CustomEmojiService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.customEmojiService.addAliasesBulk(ps.ids, ps.aliases); await this.customEmojiService.addAliasesBulk(ps.ids, ps.aliases.map(a => a.normalize('NFC')));
}); });
} }
} }

View File

@ -40,7 +40,7 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, name: { type: 'string', pattern: '^[\\p{Letter}\\p{Number}\\p{Mark}_+-]+$' },
fileId: { type: 'string', format: 'misskey:id' }, fileId: { type: 'string', format: 'misskey:id' },
category: { category: {
type: 'string', type: 'string',
@ -73,18 +73,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private emojiEntityService: EmojiEntityService, private emojiEntityService: EmojiEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const nameNfc = ps.name.normalize('NFC');
const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); const driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
if (driveFile == null) throw new ApiError(meta.errors.noSuchFile); if (driveFile == null) throw new ApiError(meta.errors.noSuchFile);
const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); const isDuplicate = await this.customEmojiService.checkDuplicate(nameNfc);
if (isDuplicate) throw new ApiError(meta.errors.duplicateName); if (isDuplicate) throw new ApiError(meta.errors.duplicateName);
if (driveFile.user !== null) await this.driveFilesRepository.update(driveFile.id, { user: null }); if (driveFile.user !== null) await this.driveFilesRepository.update(driveFile.id, { user: null });
const emoji = await this.customEmojiService.add({ const emoji = await this.customEmojiService.add({
driveFile, driveFile,
name: ps.name, name: nameNfc,
category: ps.category ?? null, category: ps.category?.normalize('NFC') ?? null,
aliases: ps.aliases ?? [], aliases: ps.aliases?.map(a => a.normalize('NFC')) ?? [],
host: null, host: null,
license: ps.license ?? null, license: ps.license ?? null,
isSensitive: ps.isSensitive ?? false, isSensitive: ps.isSensitive ?? false,

View File

@ -82,15 +82,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(); throw new ApiError();
} }
const nameNfc = emoji.name.normalize('NFC');
// Duplication Check // Duplication Check
const isDuplicate = await this.customEmojiService.checkDuplicate(emoji.name); const isDuplicate = await this.customEmojiService.checkDuplicate(nameNfc);
if (isDuplicate) throw new ApiError(meta.errors.duplicateName); if (isDuplicate) throw new ApiError(meta.errors.duplicateName);
const addedEmoji = await this.customEmojiService.add({ const addedEmoji = await this.customEmojiService.add({
driveFile, driveFile,
name: emoji.name, name: nameNfc,
category: emoji.category, category: emoji.category?.normalize('NFC'),
aliases: emoji.aliases, aliases: emoji.aliases?.map(a => a.normalize('NFC')),
host: null, host: null,
license: emoji.license, license: emoji.license,
isSensitive: emoji.isSensitive, isSensitive: emoji.isSensitive,

View File

@ -98,7 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
if (ps.query) { if (ps.query) {
q.andWhere('emoji.name like :query', { query: '%' + sqlLikeEscape(ps.query) + '%' }) q.andWhere('emoji.name like :query', { query: '%' + sqlLikeEscape(ps.query.normalize('NFC')) + '%' })
.orderBy('length(emoji.name)', 'ASC'); .orderBy('length(emoji.name)', 'ASC');
} }

View File

@ -92,17 +92,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
//const emojis = await q.limit(ps.limit).getMany(); //const emojis = await q.limit(ps.limit).getMany();
emojis = await q.orderBy('length(emoji.name)', 'ASC').getMany(); emojis = await q.orderBy('length(emoji.name)', 'ASC').getMany();
const queryarry = ps.query.match(/\:([a-z0-9_]*)\:/g); const queryarry = ps.query.match(/:([\p{Letter}\p{Number}\p{Mark}_+-]*):/ug);
if (queryarry) { if (queryarry) {
emojis = emojis.filter(emoji => emojis = emojis.filter(emoji =>
queryarry.includes(`:${emoji.name}:`), queryarry.includes(`:${emoji.name.normalize('NFC')}:`),
); );
} else { } else {
const queryNfc = ps.query!.normalize('NFC');
emojis = emojis.filter(emoji => emojis = emojis.filter(emoji =>
emoji.name.includes(ps.query!) || emoji.name.includes(queryNfc) ||
emoji.aliases.some(a => a.includes(ps.query!)) || emoji.aliases.some(a => a.includes(queryNfc)) ||
emoji.category?.includes(ps.query!)); emoji.category?.includes(queryNfc));
} }
emojis.splice(ps.limit + 1); emojis.splice(ps.limit + 1);
} else { } else {

View File

@ -34,7 +34,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private customEmojiService: CustomEmojiService, private customEmojiService: CustomEmojiService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.customEmojiService.removeAliasesBulk(ps.ids, ps.aliases); await this.customEmojiService.removeAliasesBulk(ps.ids, ps.aliases.map(a => a.normalize('NFC')));
}); });
} }
} }

View File

@ -34,7 +34,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private customEmojiService: CustomEmojiService, private customEmojiService: CustomEmojiService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.customEmojiService.setAliasesBulk(ps.ids, ps.aliases); await this.customEmojiService.setAliasesBulk(ps.ids, ps.aliases.map(a => a.normalize('NFC')));
}); });
} }
} }

View File

@ -36,7 +36,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private customEmojiService: CustomEmojiService, private customEmojiService: CustomEmojiService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
await this.customEmojiService.setCategoryBulk(ps.ids, ps.category ?? null); await this.customEmojiService.setCategoryBulk(ps.ids, ps.category?.normalize('NFC') ?? null);
}); });
} }
} }

View File

@ -40,7 +40,7 @@ export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
id: { type: 'string', format: 'misskey:id' }, id: { type: 'string', format: 'misskey:id' },
name: { type: 'string', pattern: '^[a-zA-Z0-9_]+$' }, name: { type: 'string', pattern: '^[\\p{Letter}\\p{Number}\\p{Mark}_+-]+$' },
fileId: { type: 'string', format: 'misskey:id' }, fileId: { type: 'string', format: 'misskey:id' },
category: { category: {
type: 'string', type: 'string',
@ -72,6 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private customEmojiService: CustomEmojiService, private customEmojiService: CustomEmojiService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const nameNfc = ps.name?.normalize('NFC');
let driveFile; let driveFile;
if (ps.fileId) { if (ps.fileId) {
driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId }); driveFile = await this.driveFilesRepository.findOneBy({ id: ps.fileId });
@ -83,22 +84,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
emojiId = ps.id; emojiId = ps.id;
const emoji = await this.customEmojiService.getEmojiById(ps.id); const emoji = await this.customEmojiService.getEmojiById(ps.id);
if (!emoji) throw new ApiError(meta.errors.noSuchEmoji); if (!emoji) throw new ApiError(meta.errors.noSuchEmoji);
if (ps.name && (ps.name !== emoji.name)) { if (nameNfc && (nameNfc !== emoji.name)) {
const isDuplicate = await this.customEmojiService.checkDuplicate(ps.name); const isDuplicate = await this.customEmojiService.checkDuplicate(nameNfc);
if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists); if (isDuplicate) throw new ApiError(meta.errors.sameNameEmojiExists);
} }
} else { } else {
if (!ps.name) throw new Error('Invalid Params unexpectedly passed. This is a BUG. Please report it to the development team.'); if (!nameNfc) throw new Error('Invalid Params unexpectedly passed. This is a BUG. Please report it to the development team.');
const emoji = await this.customEmojiService.getEmojiByName(ps.name); const emoji = await this.customEmojiService.getEmojiByName(nameNfc);
if (!emoji) throw new ApiError(meta.errors.noSuchEmoji); if (!emoji) throw new ApiError(meta.errors.noSuchEmoji);
emojiId = emoji.id; emojiId = emoji.id;
} }
await this.customEmojiService.update(emojiId, { await this.customEmojiService.update(emojiId, {
driveFile, driveFile,
name: ps.name, name: nameNfc,
category: ps.category, category: ps.category?.normalize('NFC'),
aliases: ps.aliases, aliases: ps.aliases?.map(a => a.normalize('NFC')),
license: ps.license, license: ps.license,
isSensitive: ps.isSensitive, isSensitive: ps.isSensitive,
localOnly: ps.localOnly, localOnly: ps.localOnly,

View File

@ -278,7 +278,7 @@ export class MastoConverters {
reactions: status.emoji_reactions, reactions: status.emoji_reactions,
emoji_reactions: status.emoji_reactions, emoji_reactions: status.emoji_reactions,
bookmarked: false, bookmarked: false,
quote: isQuote ? await this.convertReblog(status.reblog) : false, quote: isQuote ? await this.convertReblog(status.reblog) : null,
// optional chaining cannot be used, as it evaluates to undefined, not null // optional chaining cannot be used, as it evaluates to undefined, not null
edited_at: note.updatedAt ? note.updatedAt.toISOString() : null, edited_at: note.updatedAt ? note.updatedAt.toISOString() : null,
}); });

View File

@ -21,10 +21,11 @@
"@github/webauthn-json": "2.1.1", "@github/webauthn-json": "2.1.1",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3", "@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@misskey-dev/browser-image-resizer": "2024.1.0", "@misskey-dev/browser-image-resizer": "2024.1.0",
"@phosphor-icons/web": "^2.0.3",
"@rollup/plugin-json": "6.1.0", "@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "5.0.5", "@rollup/plugin-replace": "5.0.5",
"@rollup/pluginutils": "5.1.0", "@rollup/pluginutils": "5.1.0",
"@transfem-org/sfm-js": "0.24.4", "@transfem-org/sfm-js": "0.24.5",
"@syuilo/aiscript": "0.18.0", "@syuilo/aiscript": "0.18.0",
"@phosphor-icons/web": "^2.0.3", "@phosphor-icons/web": "^2.0.3",
"@twemoji/parser": "15.0.0", "@twemoji/parser": "15.0.0",

View File

@ -238,7 +238,7 @@ function exec() {
return; return;
} }
emojis.value = searchEmoji(props.q.toLowerCase(), emojiDb.value); emojis.value = searchEmoji(props.q.normalize('NFC').toLowerCase(), emojiDb.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

@ -205,7 +205,7 @@ watch(q, () => {
return; return;
} }
const newQ = q.value.replace(/:/g, '').toLowerCase(); const newQ = q.value.replace(/:/g, '').normalize('NFC').toLowerCase();
const searchCustom = () => { const searchCustom = () => {
const max = 100; const max = 100;

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div> <div>
<MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }"> <MkPagination v-slot="{items}" :pagination="pagination" :displayLimit="50" class="urempief" :class="{ grid: viewMode === 'grid' }">
<MkA <MkA
v-for="file in (items as Misskey.entities.DriveFile[])" v-for="file in (items as Misskey.entities.DriveFile[])"
:key="file.id" :key="file.id"

View File

@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header> <template #header>
<template v-if="pageMetadata"> <template v-if="pageMetadata">
<i v-if="pageMetadata.icon" :class="pageMetadata.icon" style="margin-right: 0.5em;"></i> <i v-if="pageMetadata.icon" :class="pageMetadata.icon" style="margin-right: 0.5em;"></i>
<span>{{ pageMetadata.title }}</span> <span><MkUserName v-if="pageMetadata.userName?.name" :user="pageMetadata.userName" />{{ pageMetadata.title }}</span>
</template> </template>
</template> </template>
@ -43,6 +43,7 @@ import { claimAchievement } from '@/scripts/achievements.js';
import { getScrollContainer } from '@/scripts/scroll.js'; import { getScrollContainer } from '@/scripts/scroll.js';
import { useRouterFactory } from '@/router/supplier.js'; import { useRouterFactory } from '@/router/supplier.js';
import { mainRouter } from '@/router/main.js'; import { mainRouter } from '@/router/main.js';
import MkUserName from './global/MkUserName.vue';
const props = defineProps<{ const props = defineProps<{
initialPath: string; initialPath: string;

View File

@ -395,10 +395,10 @@ const prepend = (item: MisskeyEntity): void => {
* @param newItems 新しいアイテムの配列 * @param newItems 新しいアイテムの配列
*/ */
function unshiftItems(newItems: MisskeyEntity[]) { function unshiftItems(newItems: MisskeyEntity[]) {
const length = newItems.length + items.value.size; const prevLength = items.value.size;
items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit)); items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, newItems.length + props.displayLimit));
// if we truncated, mark that there are more values to fetch
if (length >= props.displayLimit) more.value = true; if (items.value.size < prevLength) more.value = true;
} }
/** /**
@ -406,10 +406,10 @@ function unshiftItems(newItems: MisskeyEntity[]) {
* @param oldItems 古いアイテムの配列 * @param oldItems 古いアイテムの配列
*/ */
function concatItems(oldItems: MisskeyEntity[]) { function concatItems(oldItems: MisskeyEntity[]) {
const length = oldItems.length + items.value.size; const prevLength = items.value.size;
items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit)); items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, oldItems.length + props.displayLimit));
// if we truncated, mark that there are more values to fetch
if (length >= props.displayLimit) more.value = true; if (items.value.size < prevLength) more.value = true;
} }
function executeQueue() { function executeQueue() {
@ -418,7 +418,7 @@ function executeQueue() {
} }
function prependQueue(newItem: MisskeyEntity) { function prependQueue(newItem: MisskeyEntity) {
queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]); queue.value = new Map([[newItem.id, newItem], ...queue.value] as [string, MisskeyEntity][]);
} }
/* /*

View File

@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { instance } from '@/instance.js'; import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from '@/scripts/sanitize-html.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';

View File

@ -16,9 +16,20 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div v-else-if="phase === 'howToReact'" class="_gaps"> <div v-else-if="phase === 'howToReact'" class="_gaps">
<div style="text-align: center; padding: 0 16px;">{{ i18n.ts._initialTutorial._reaction.description }}</div> <div style="text-align: center; padding: 0 16px;">{{ i18n.ts._initialTutorial._reaction.description }}</div>
<div>{{ i18n.ts._initialTutorial._reaction.letsTryReacting }}</div> <I18n :src="i18n.ts._initialTutorial._reaction.letsTryReacting" tag="div">
<template #reaction>
<i class="ph-smiley ph-bold ph-lg"></i>
</template>
</I18n>
<MkNote :class="$style.exampleNoteRoot" :note="exampleNote" :mock="true" @reaction="addReaction" @removeReaction="removeReaction"/> <MkNote :class="$style.exampleNoteRoot" :note="exampleNote" :mock="true" @reaction="addReaction" @removeReaction="removeReaction"/>
<div v-if="onceReacted"><b style="color: var(--accent);"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts._initialTutorial.wellDone }}</b> {{ i18n.ts._initialTutorial._reaction.reactNotification }}<br>{{ i18n.ts._initialTutorial._reaction.reactDone }}</div> <div v-if="onceReacted">
<b style="color: var(--accent);"><i class="ph-check ph-bold ph-lg"></i> {{ i18n.ts._initialTutorial.wellDone }}</b> {{ i18n.ts._initialTutorial._reaction.reactNotification }}<br>
<I18n :src="i18n.ts._initialTutorial._reaction.reactDone">
<template #undo>
<i class="ph-minus ph-bold ph-lg"></i>
</template>
</I18n>
</div>
</div> </div>
</template> </template>

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<MkPagination :pagination="pagination"> <MkPagination :pagination="pagination" :displayLimit="50">
<template #empty> <template #empty>
<div class="_fullinfo"> <div class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/> <img :src="infoImageUrl" class="_ghost"/>

View File

@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue'; import { ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from '@/scripts/sanitize-html.js';
import XSigninDialog from '@/components/MkSigninDialog.vue'; import XSigninDialog from '@/components/MkSigninDialog.vue';
import XSignupDialog from '@/components/MkSignupDialog.vue'; import XSignupDialog from '@/components/MkSignupDialog.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';

View File

@ -85,6 +85,7 @@ const errored = ref(url.value == null);
function onClick(ev: MouseEvent) { function onClick(ev: MouseEvent) {
if (props.menu) { if (props.menu) {
ev.stopPropagation();
os.popupMenu([{ os.popupMenu([{
type: 'label', type: 'label',
text: `:${props.name}:`, text: `:${props.name}:`,

View File

@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<img v-if="!useOsNativeEmojis" :class="$style.root" :src="url" :alt="props.emoji" decoding="async" @pointerenter="computeTitle" @click="onClick" v-on:click.stop/> <img v-if="!useOsNativeEmojis" :class="$style.root" :src="url" :alt="props.emoji" decoding="async" @pointerenter="computeTitle" @click="onClick"/>
<span v-else :alt="props.emoji" @pointerenter="computeTitle" @click="onClick" v-on:click.stop>{{ colorizedNativeEmoji }}</span> <span v-else :alt="props.emoji" @pointerenter="computeTitle" @click="onClick">{{ colorizedNativeEmoji }}</span>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -39,6 +39,7 @@ function computeTitle(event: PointerEvent): void {
function onClick(ev: MouseEvent) { function onClick(ev: MouseEvent) {
if (props.menu) { if (props.menu) {
ev.stopPropagation();
os.popupMenu([{ os.popupMenu([{
type: 'label', type: 'label',
text: props.emoji, text: props.emoji,

View File

@ -42,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</FormSplit> </FormSplit>
</div> </div>
<MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination"> <MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination" :displayLimit="50">
<div :class="$style.items"> <div :class="$style.items">
<MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" :class="$style.item" :to="`/instance-info/${instance.host}`"> <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" :class="$style.item" :to="`/instance-info/${instance.host}`">
<MkInstanceCardMini :instance="instance"/> <MkInstanceCardMini :instance="instance"/>

View File

@ -130,7 +130,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from '@/scripts/sanitize-html.js';
import { computed, watch, ref } from 'vue'; import { computed, watch, ref } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import XEmojis from './about.emojis.vue'; import XEmojis from './about.emojis.vue';

View File

@ -41,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
--> -->
<MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);"> <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" :displayLimit="50" style="margin-top: var(--margin);">
<XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/> <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
</MkPagination> </MkPagination>
</div> </div>

View File

@ -4,7 +4,7 @@
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="900"> <MkSpacer :contentMax="900">
<div class="_gaps_m"> <div class="_gaps_m">
<MkPagination ref="paginationComponent" :pagination="pagination"> <MkPagination ref="paginationComponent" :pagination="pagination" :displayLimit="50">
<template #default="{ items }"> <template #default="{ items }">
<div class="_gaps_s"> <div class="_gaps_s">
<SkApprovalUser v-for="item in items" :key="item.id" :user="(item as any)" :onDeleted="deleted"/> <SkApprovalUser v-for="item in items" :key="item.id" :user="(item as any)" :onDeleted="deleted"/>

View File

@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</FormSplit> </FormSplit>
</div> </div>
<MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination"> <MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination" :displayLimit="50">
<div :class="$style.instances"> <div :class="$style.instances">
<MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" :class="$style.instance" :to="`/instance-info/${instance.host}`"> <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" :class="$style.instance" :to="`/instance-info/${instance.host}`">
<MkInstanceCardMini :instance="instance"/> <MkInstanceCardMini :instance="instance"/>

View File

@ -42,7 +42,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<option value="-usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.descendingOrder }})</option> <option value="-usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.descendingOrder }})</option>
</MkSelect> </MkSelect>
</div> </div>
<MkPagination ref="pagingComponent" :pagination="pagination"> <MkPagination ref="pagingComponent" :pagination="pagination" :displayLimit="50">
<template #default="{ items }"> <template #default="{ items }">
<div class="_gaps_s"> <div class="_gaps_s">
<MkInviteCode v-for="item in items" :key="item.id" :invite="(item as any)" :onDeleted="deleted" moderator/> <MkInviteCode v-for="item in items" :key="item.id" :invite="(item as any)" :onDeleted="deleted" moderator/>

View File

@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput> </MkInput>
</div> </div>
<MkPagination v-slot="{items}" ref="logs" :pagination="pagination" style="margin-top: var(--margin);"> <MkPagination v-slot="{items}" ref="logs" :pagination="pagination" :displayLimit="50" style="margin-top: var(--margin);">
<div class="_gaps_s"> <div class="_gaps_s">
<XModLog v-for="item in items" :key="item.id" :log="item"/> <XModLog v-for="item in items" :key="item.id" :log="item"/>
</div> </div>

View File

@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps"> <div class="_gaps">
<MkButton primary rounded @click="assign"><i class="ph-plus ph-bold ph-lg"></i> {{ i18n.ts.assign }}</MkButton> <MkButton primary rounded @click="assign"><i class="ph-plus ph-bold ph-lg"></i> {{ i18n.ts.assign }}</MkButton>
<MkPagination :pagination="usersPagination"> <MkPagination :pagination="usersPagination" :displayLimit="50">
<template #empty> <template #empty>
<div class="_fullinfo"> <div class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/> <img :src="infoImageUrl" class="_ghost"/>

View File

@ -44,7 +44,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput> </MkInput>
</div> </div>
<MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination"> <MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" :displayLimit="50">
<div :class="$style.users"> <div :class="$style.users">
<MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${dateString(user.updatedAt)}`" :class="$style.user" :to="`/admin/user/${user.id}`"> <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${dateString(user.updatedAt)}`" :class="$style.user" :to="`/admin/user/${user.id}`">
<MkUserCardMini :user="user"/> <MkUserCardMini :user="user"/>

View File

@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton inline @click="setLicenseBulk">Set License</MkButton> <MkButton inline @click="setLicenseBulk">Set License</MkButton>
<MkButton inline danger @click="delBulk">Delete</MkButton> <MkButton inline danger @click="delBulk">Delete</MkButton>
</div> </div>
<MkPagination ref="emojisPaginationComponent" :pagination="pagination"> <MkPagination ref="emojisPaginationComponent" :pagination="pagination" :displayLimit="50">
<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
<template #default="{items}"> <template #default="{items}">
<div class="ldhfsamy"> <div class="ldhfsamy">
@ -52,7 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.host }}</template> <template #label>{{ i18n.ts.host }}</template>
</MkInput> </MkInput>
</FormSplit> </FormSplit>
<MkPagination :pagination="remotePagination"> <MkPagination :pagination="remotePagination" :displayLimit="50">
<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template> <template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
<template #default="{items}"> <template #default="{items}">
<div class="ldhfsamy"> <div class="ldhfsamy">
@ -352,6 +352,7 @@ definePageMetadata(() => ({
> .img { > .img {
width: 42px; width: 42px;
height: 42px; height: 42px;
object-fit: contain;
} }
> .body { > .body {
@ -398,6 +399,7 @@ definePageMetadata(() => ({
> .img { > .img {
width: 32px; width: 32px;
height: 32px; height: 32px;
object-fit: contain;
} }
> .body { > .body {

View File

@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</div> </div>
<MkButton rounded style="margin: 0 auto;" @click="changeImage">{{ i18n.ts.selectFile }}</MkButton> <MkButton rounded style="margin: 0 auto;" @click="changeImage">{{ i18n.ts.selectFile }}</MkButton>
<MkInput v-model="name" pattern="[a-z0-9_]" autocapitalize="off"> <MkInput v-model="name" autocapitalize="off">
<template #label>{{ i18n.ts.name }}</template> <template #label>{{ i18n.ts.name }}</template>
</MkInput> </MkInput>
<MkInput v-model="category" :datalist="customEmojiCategories"> <MkInput v-model="category" :datalist="customEmojiCategories">

View File

@ -7,11 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps_m"> <div class="_gaps_m">
<MkSelect v-model="type"> <MkSelect v-model="type">
<option value="all">{{ i18n.ts.all }}</option> <option value="all">{{ i18n.ts.all }}</option>
<option value="following" v-if="hasSender">{{ i18n.ts.following }}</option> <option v-if="hasSender" value="following">{{ i18n.ts.following }}</option>
<option value="follower" v-if="hasSender">{{ i18n.ts.followers }}</option> <option v-if="hasSender" value="follower">{{ i18n.ts.followers }}</option>
<option value="mutualFollow" v-if="hasSender">{{ i18n.ts.mutualFollow }}</option> <option v-if="hasSender" value="mutualFollow">{{ i18n.ts.mutualFollow }}</option>
<option value="followingOrFollower" v-if="hasSender">{{ i18n.ts.followingOrFollower }}</option> <option v-if="hasSender" value="followingOrFollower">{{ i18n.ts.followingOrFollower }}</option>
<option value="list" v-if="hasSender">{{ i18n.ts.userList }}</option> <option v-if="hasSender" value="list">{{ i18n.ts.userList }}</option>
<option value="never">{{ i18n.ts.none }}</option> <option value="never">{{ i18n.ts.none }}</option>
</MkSelect> </MkSelect>

View File

@ -140,6 +140,7 @@ type Profile = {
hot: Record<keyof typeof defaultStoreSaveKeys, unknown>; hot: Record<keyof typeof defaultStoreSaveKeys, unknown>;
cold: Record<keyof typeof coldDeviceStorageSaveKeys, unknown>; cold: Record<keyof typeof coldDeviceStorageSaveKeys, unknown>;
fontSize: string | null; fontSize: string | null;
lang: string | null;
cornerRadius: string | null; cornerRadius: string | null;
useSystemFont: 't' | null; useSystemFont: 't' | null;
wallpaper: string | null; wallpaper: string | null;
@ -198,6 +199,7 @@ function getSettings(): Profile['settings'] {
hot, hot,
cold, cold,
fontSize: miLocalStorage.getItem('fontSize'), fontSize: miLocalStorage.getItem('fontSize'),
lang: miLocalStorage.getItem('lang'),
cornerRadius: miLocalStorage.getItem('cornerRadius'), cornerRadius: miLocalStorage.getItem('cornerRadius'),
useSystemFont: miLocalStorage.getItem('useSystemFont') as 't' | null, useSystemFont: miLocalStorage.getItem('useSystemFont') as 't' | null,
wallpaper: miLocalStorage.getItem('wallpaper'), wallpaper: miLocalStorage.getItem('wallpaper'),
@ -313,6 +315,13 @@ async function applyProfile(id: string): Promise<void> {
miLocalStorage.removeItem('fontSize'); miLocalStorage.removeItem('fontSize');
} }
// lang
if (settings.lang) {
miLocalStorage.setItem('lang', settings.lang);
} else {
miLocalStorage.removeItem('lang');
}
// cornerRadius // cornerRadius
if (settings.cornerRadius) { if (settings.cornerRadius) {
miLocalStorage.setItem('cornerRadius', settings.cornerRadius); miLocalStorage.setItem('cornerRadius', settings.cornerRadius);

View File

@ -130,7 +130,7 @@ definePageMetadata(() => ({
title: i18n.ts.user, title: i18n.ts.user,
icon: 'ph-user ph-bold ph-lg', icon: 'ph-user ph-bold ph-lg',
...user.value ? { ...user.value ? {
title: user.value.name ? `${user.value.name} (@${user.value.username})` : `@${user.value.username}`, title: user.value.name ? ` (@${user.value.username})` : `@${user.value.username}`,
subtitle: `@${getAcct(user.value)}`, subtitle: `@${getAcct(user.value)}`,
userName: user.value, userName: user.value,
avatar: user.value, avatar: user.value,

View File

@ -99,7 +99,7 @@ export class Autocomplete {
const isHashtag = hashtagIndex !== -1; const isHashtag = hashtagIndex !== -1;
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(/:[\p{Letter}\p{Number}\p{Mark}_+-]+:/u).pop()!.includes(':');
let opened = false; let opened = false;
@ -125,7 +125,7 @@ export class Autocomplete {
if (isEmoji && !opened && this.onlyType.includes('emoji')) { if (isEmoji && !opened && this.onlyType.includes('emoji')) {
const emoji = text.substring(emojiIndex + 1); const emoji = text.substring(emojiIndex + 1);
if (!emoji.includes(' ')) { if (!emoji.includes(' ')) {
this.open('emoji', emoji); this.open('emoji', emoji.normalize('NFC'));
opened = true; opened = true;
} }
} }

View File

@ -3,12 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
export function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: Array<string | string[]>): boolean { import type { Note, MeDetailed } from "misskey-js/entities.js";
export function checkWordMute(note: Note, me: MeDetailed | null | undefined, mutedWords: Array<string | string[]>): boolean {
// 自分自身 // 自分自身
if (me && (note.userId === me.id)) return false; if (me && (note.userId === me.id)) return false;
if (mutedWords.length > 0) { if (mutedWords.length > 0) {
const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim(); const text = getNoteText(note);
if (text === '') return false; if (text === '') return false;
@ -40,3 +42,25 @@ export function checkWordMute(note: Record<string, any>, me: Record<string, any>
return false; return false;
} }
function getNoteText(note: Note): string {
const textParts: string[] = [];
if (note.cw)
textParts.push(note.cw);
if (note.text)
textParts.push(note.text);
if (note.files)
for (const file of note.files)
if (file.comment)
textParts.push(file.comment);
if (note.poll)
for (const choice of note.poll.choices)
if (choice.text)
textParts.push(choice.text);
return textParts.join('\n').trim();
}

View File

@ -1,4 +1,3 @@
// @ts-nocheck
/* eslint-disable */ /* eslint-disable */
const ChiptuneAudioContext = window.AudioContext || window.webkitAudioContext; const ChiptuneAudioContext = window.AudioContext || window.webkitAudioContext;
@ -6,6 +5,11 @@ const ChiptuneAudioContext = window.AudioContext || window.webkitAudioContext;
let libopenmpt let libopenmpt
let libopenmptLoadPromise let libopenmptLoadPromise
type ChiptuneJsConfig = {
repeatCount: number | null;
context: AudioContext | null;
};
export function ChiptuneJsConfig (repeatCount?: number, context?: AudioContext) { export function ChiptuneJsConfig (repeatCount?: number, context?: AudioContext) {
this.repeatCount = repeatCount; this.repeatCount = repeatCount;
this.context = context; this.context = context;
@ -13,7 +17,7 @@ export function ChiptuneJsConfig (repeatCount?: number, context?: AudioContext)
ChiptuneJsConfig.prototype.constructor = ChiptuneJsConfig; ChiptuneJsConfig.prototype.constructor = ChiptuneJsConfig;
export function ChiptuneJsPlayer (config: object) { export function ChiptuneJsPlayer (config: ChiptuneJsConfig) {
this.config = config; this.config = config;
this.audioContext = config.context || new ChiptuneAudioContext(); this.audioContext = config.context || new ChiptuneAudioContext();
this.context = this.audioContext.createGain(); this.context = this.audioContext.createGain();
@ -27,7 +31,7 @@ ChiptuneJsPlayer.prototype.initialize = function() {
if (libopenmptLoadPromise) return libopenmptLoadPromise; if (libopenmptLoadPromise) return libopenmptLoadPromise;
if (libopenmpt) return Promise.resolve(); if (libopenmpt) return Promise.resolve();
libopenmptLoadPromise = new Promise(async (resolve, reject) => { libopenmptLoadPromise = new Promise<void>(async (resolve, reject) => {
try { try {
const { Module } = await import('./libopenmpt/libopenmpt.js'); const { Module } = await import('./libopenmpt/libopenmpt.js');
await new Promise((resolve) => { await new Promise((resolve) => {

View File

@ -9,9 +9,9 @@ const koRegex3 = /(야(?=\?))|(야$)|(야(?= ))/gm;
function ifAfter(prefix, fn) { function ifAfter(prefix, fn) {
const preLen = prefix.length; const preLen = prefix.length;
const regex = new RegExp(prefix,'i'); const regex = new RegExp(prefix, 'i');
return (x,pos,string) => { return (x, pos, string) => {
return pos > 0 && string.substring(pos-preLen,pos).match(regex) ? fn(x) : x; return pos > 0 && string.substring(pos - preLen, pos).match(regex) ? fn(x) : x;
}; };
} }
@ -25,7 +25,7 @@ export function nyaize(text: string): string {
.replace(/one/gi, ifAfter('every', x => x === 'ONE' ? 'NYAN' : 'nyan')) .replace(/one/gi, ifAfter('every', x => x === 'ONE' ? 'NYAN' : 'nyan'))
// ko-KR // ko-KR
.replace(koRegex1, match => String.fromCharCode( .replace(koRegex1, match => String.fromCharCode(
match.charCodeAt(0)! + '냐'.charCodeAt(0) - '나'.charCodeAt(0), match.charCodeAt(0) + '냐'.charCodeAt(0) - '나'.charCodeAt(0),
)) ))
.replace(koRegex2, '다냥') .replace(koRegex2, '다냥')
.replace(koRegex3, '냥'); .replace(koRegex3, '냥');

View File

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: dakkar and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import original from 'sanitize-html';
export default function sanitizeHtml(str: string | null): string | null {
if (str == null) return str;
return original(str, {
allowedTags: original.defaults.allowedTags.concat(['img', 'audio', 'video', 'center', 'details', 'summary']),
allowedAttributes: {
...original.defaults.allowedAttributes,
a: original.defaults.allowedAttributes.a.concat(['style']),
img: original.defaults.allowedAttributes.img.concat(['style']),
},
});
}

View File

@ -39,7 +39,7 @@ namespace Entity {
language: string | null language: string | null
pinned: boolean | null pinned: boolean | null
emoji_reactions: Array<Reaction> emoji_reactions: Array<Reaction>
quote: Status | boolean quote: Status | boolean | null
bookmarked: boolean bookmarked: boolean
} }

View File

@ -304,7 +304,7 @@ namespace MisskeyAPI {
pinned: null, pinned: null,
emoji_reactions: typeof n.reactions === 'object' ? mapReactions(n.reactions, n.myReaction) : [], emoji_reactions: typeof n.reactions === 'object' ? mapReactions(n.reactions, n.myReaction) : [],
bookmarked: false, bookmarked: false,
quote: n.renote && n.text ? note(n.renote, n.user.host ? n.user.host : host ? host : null) : false quote: n.renote && n.text ? note(n.renote, n.user.host ? n.user.host : host ? host : null) : null
} }
} }

View File

@ -149,8 +149,8 @@ importers:
specifier: 1.3.107 specifier: 1.3.107
version: 1.3.107 version: 1.3.107
'@transfem-org/sfm-js': '@transfem-org/sfm-js':
specifier: 0.24.4 specifier: 0.24.5
version: 0.24.4 version: 0.24.5
'@twemoji/parser': '@twemoji/parser':
specifier: 15.0.0 specifier: 15.0.0
version: 15.0.0 version: 15.0.0
@ -718,8 +718,8 @@ importers:
specifier: 0.18.0 specifier: 0.18.0
version: 0.18.0 version: 0.18.0
'@transfem-org/sfm-js': '@transfem-org/sfm-js':
specifier: 0.24.4 specifier: 0.24.5
version: 0.24.4 version: 0.24.5
'@twemoji/parser': '@twemoji/parser':
specifier: 15.0.0 specifier: 15.0.0
version: 15.0.0 version: 15.0.0
@ -7333,8 +7333,8 @@ packages:
resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==}
dev: false dev: false
/@transfem-org/sfm-js@0.24.4: /@transfem-org/sfm-js@0.24.5:
resolution: {integrity: sha1-0wEXqL5UJseGFO4GGFRrES6NCDk=, tarball: https://activitypub.software/api/v4/projects/2/packages/npm/@transfem-org/sfm-js/-/@transfem-org/sfm-js-0.24.4.tgz} resolution: {integrity: sha1-c9qJO12lIG+kovDGKjZmK2qPqcw=, tarball: https://activitypub.software/api/v4/projects/2/packages/npm/@transfem-org/sfm-js/-/@transfem-org/sfm-js-0.24.5.tgz}
dependencies: dependencies:
'@twemoji/parser': 15.0.0 '@twemoji/parser': 15.0.0
dev: false dev: false