Merge branch 'develop' into gh-fa55fa5e/10452/unknown/rtl
This commit is contained in:
commit
e956bd98a0
88 changed files with 3228 additions and 1852 deletions
4
COPYING
4
COPYING
|
@ -28,6 +28,10 @@ Machine learning model for sensitive images by Infinite Red, Inc.
|
|||
License: MIT
|
||||
https://github.com/infinitered/nsfwjs/blob/master/LICENSE
|
||||
|
||||
Chiptune2.js by Simon Gündling
|
||||
License: MIT
|
||||
https://github.com/deskjet/chiptune2.js#license
|
||||
|
||||
Licenses for all softwares and software libraries installed via the Node Package Manager ("npm") can be found by running the following shell command in the root directory of this repository:
|
||||
|
||||
pnpm licenses list
|
||||
|
|
|
@ -267,6 +267,7 @@ pm2 start "NODE_ENV=production pnpm run start" --name Firefish
|
|||
- When editing the config file, please don't fill out the settings at the bottom. They're designed *only* for managed hosting, not self hosting. Those settings are much better off being set in Firefish's control panel.
|
||||
- Port 3000 (used in the default config) might be already used on your server for something else. To find an open port for Firefish, run `for p in {3000..4000}; do ss -tlnH | tr -s ' ' | cut -d" " -sf4 | grep -q "${p}$" || echo "${p}"; done | head -n 1`. Replace 3000 with the minimum port and 4000 with the maximum port if you need it.
|
||||
- I'd recommend you use a S3 Bucket/CDN for Object Storage, especially if you use Docker.
|
||||
- When using object storage, setting a proper `Access-Control-Allow-Origin` response header is highly recommended.
|
||||
- I'd ***strongly*** recommend against using CloudFlare, but if you do, make sure to turn code minification off.
|
||||
- For push notifications, run `npx web-push generate-vapid-keys`, then put the public and private keys into Control Panel > General > ServiceWorker.
|
||||
- For translations, make a [DeepL](https://deepl.com) account and generate an API key, then put it into Control Panel > General > DeepL Translation.
|
||||
|
|
BIN
custom/assets/badges/error.avif
Normal file
BIN
custom/assets/badges/error.avif
Normal file
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 40 KiB |
BIN
custom/assets/badges/info.avif
Normal file
BIN
custom/assets/badges/info.avif
Normal file
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 9.9 KiB |
BIN
custom/assets/badges/not-found.avif
Normal file
BIN
custom/assets/badges/not-found.avif
Normal file
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 9.1 KiB |
|
@ -4,6 +4,8 @@ Breaking changes are indecated by the :warning: icon.
|
|||
|
||||
## v1.0.5 (unreleased)
|
||||
|
||||
- Added `lang` parameter to `notes/create` and `notes/edit`.
|
||||
|
||||
### dev11
|
||||
|
||||
- :warning: `notes/translate` now requires credentials.
|
||||
|
|
|
@ -103,4 +103,4 @@ NODE_ENV=production pnpm run migrate
|
|||
|
||||
## Reverse
|
||||
|
||||
You ***cannot*** migrate back to Misskey from Firefish due to re-hashing passwords on signin with argon2. You can migrate from Firefish to FoundKey, although this is not recommended due to FoundKey being end-of-life, and may have some problems with alt-text.
|
||||
You ***cannot*** migrate back to Misskey from Firefish due to re-hashing passwords on signin with argon2. You can migrate to [Sharkey](https://github.com/transfem-org/Sharkey), a soft fork of Misskey that uses argon2 though. You can also migrate from Firefish to FoundKey, although this is not recommended due to FoundKey being end-of-life, and may have some problems with alt-text.
|
||||
|
|
|
@ -1179,7 +1179,7 @@ emptyToDisableSmtpAuth: Deixa el nom d'usuari i la contrasenya sense emplenar pe
|
|||
desactivar la verificació SMTP
|
||||
smtpSecureInfo: Desactiva això quant facis servir STARTTLS
|
||||
testEmail: Envia un correu electrònic de verificació
|
||||
wordMute: Silenciar paraules
|
||||
wordMute: Paraules i llenguatge silenciats
|
||||
regexpError: Error a la Expressió Regular
|
||||
regexpErrorDescription: 'Hi ha un error a la expressió regular a la línea {line} de
|
||||
la teva {tab} de paraules silenciades:'
|
||||
|
@ -2040,6 +2040,13 @@ _wordMute:
|
|||
s'afegeixin a la línia de temps. A més, aquestes publicacions no s'afegiran a
|
||||
la línia de temps encara que es modifiquin les condicions.
|
||||
mutedNotes: Publicacions silenciades
|
||||
muteLangsDescription2: Fes servir el codi del l'idioma. Per exemple en, fr, ja,
|
||||
zh.
|
||||
lang: Idioma
|
||||
langDescription: Amagar les publicacions que coincideixin amb l'idioma a la línia
|
||||
de temps.
|
||||
muteLangs: Llenguatges silenciats
|
||||
muteLangsDescription: Separar amb espais o línies no es per una condició OR.
|
||||
_auth:
|
||||
shareAccessAsk: Estàs segur que vols autoritzar aquesta aplicació per accedir al
|
||||
teu compte?
|
||||
|
@ -2190,3 +2197,7 @@ detectPostLanguage: Detecta l'idioma automàticament i mostra un botó per els a
|
|||
indexableDescription: Permet al cercador intern mostrar els missatges públics
|
||||
indexable: Indexable
|
||||
languageForTranslation: Idioma de traducció d'articles
|
||||
openServerInfo: Mostra la informació del servidor fent clic al símbol del servidor
|
||||
en un missatge
|
||||
vibrate: Activar vibracions
|
||||
clickToShowPatterns: Fes clic per veure patrons de mòduls
|
||||
|
|
|
@ -2213,3 +2213,5 @@ indexableDescription: Der integrierten Suche erlauben, Ihre öffentlichen Beitr
|
|||
anzuzeigen
|
||||
indexable: Indexierbar
|
||||
languageForTranslation: Übersetzungssprache veröffentlichen
|
||||
openServerInfo: Anzeigen von Serverinformationen durch Anklicken des Server-Tickers
|
||||
in einem Beitrag
|
||||
|
|
|
@ -132,6 +132,7 @@ rememberNoteVisibility: "Remember post visibility settings"
|
|||
attachCancel: "Remove attachment"
|
||||
markAsSensitive: "Mark as NSFW"
|
||||
unmarkAsSensitive: "Unmark as NSFW"
|
||||
clickToShowPatterns: "Click to show module patterns"
|
||||
enterFileName: "Enter filename"
|
||||
mute: "Mute"
|
||||
unmute: "Unmute"
|
||||
|
|
|
@ -724,8 +724,8 @@ no: "Non"
|
|||
driveFilesCount: "Nombre de fichiers dans le Drive"
|
||||
driveUsage: "Utilisation du Drive"
|
||||
noCrawle: "Refuser l'indexation par les robots"
|
||||
noCrawleDescription: "Demandez aux moteurs de recherche de ne pas indexer votre page
|
||||
de profil, vos publications, vos pages, etc."
|
||||
noCrawleDescription: "Demandez aux moteurs de recherche externes de ne pas indexer
|
||||
votre contenu."
|
||||
lockedAccountInfo: "À moins que vous ne définissiez la visibilité de votre publication
|
||||
sur \"Abonné-e-s\", vos publications sont visibles par tous, même si vous exigez
|
||||
que les demandes d'abonnement soient approuvées manuellement."
|
||||
|
@ -2210,3 +2210,11 @@ confirm: Confirmer
|
|||
importZip: Importer ZIP
|
||||
exportZip: Exporter ZIP
|
||||
emojiPackCreator: Créateur de pack d’émoji
|
||||
detectPostLanguage: Détecte automatiquement le langage et affiche un bouton de traduction
|
||||
pour les langues étrangères
|
||||
indexableDescription: Permettre à la recherche interne d’afficher vos publications
|
||||
publiques
|
||||
openServerInfo: Afficher les informations du serveur en cliquant sur le bandeau de
|
||||
serveur d’une publication
|
||||
indexable: Indexable
|
||||
languageForTranslation: Langage post-traduction
|
||||
|
|
1
locales/hi.yml
Normal file
1
locales/hi.yml
Normal file
|
@ -0,0 +1 @@
|
|||
{}
|
|
@ -637,7 +637,7 @@ emptyToDisableSmtpAuth: "Kosongkan nama pengguna dan kata sandi untuk menonaktif
|
|||
smtpSecure: "Gunakan SSL/TLS implisit untuk koneksi SMTP"
|
||||
smtpSecureInfo: "Matikan ini ketika menggunakan STARTTLS"
|
||||
testEmail: "Tes pengiriman surel"
|
||||
wordMute: "Bisukan kata"
|
||||
wordMute: "Bisukan kata dan bahasa"
|
||||
regexpError: "Kesalahan ekspresi reguler"
|
||||
regexpErrorDescription: "Galat terjadi pada baris {line} ekspresi reguler dari {tab}
|
||||
kata yang dibisukan:"
|
||||
|
@ -1135,6 +1135,12 @@ _wordMute:
|
|||
soft: "Lembut"
|
||||
hard: "Keras"
|
||||
mutedNotes: "Postingan yang dibisukan"
|
||||
muteLangsDescription2: Gunakan kode bahasa misalnya en, fr, ja, zh.
|
||||
lang: Bahasa
|
||||
langDescription: Sembunyikan postingan yang cocok dengan bahasa yang ditetapkan
|
||||
dari timeline.
|
||||
muteLangs: Bahasa yang dibisukan
|
||||
muteLangsDescription: Pisahkan dengan spasi atau jeda baris untuk kondisi ATAU.
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "Pengaturan ini akan membisukan postingan/pembagian apa
|
||||
saja dari server yang terdaftar, termasuk pengguna yang membalas pengguna lain
|
||||
|
@ -2173,3 +2179,6 @@ detectPostLanguage: Deteksi bahasa secara otomatis dan tampilkan tombol terjemah
|
|||
indexableDescription: Perbolehkan pencarian di sini untuk menampilkan kiriman publikmu
|
||||
indexable: Dapat diindeks
|
||||
languageForTranslation: Bahasa terjemahan kiriman
|
||||
openServerInfo: Tampilkan informasi server dengan mengeklik ticker server di sebuah
|
||||
kiriman
|
||||
vibrate: Putar getaran
|
||||
|
|
|
@ -626,7 +626,7 @@ emptyToDisableSmtpAuth: "Lasciare username e password vuoti per disabilitare la
|
|||
smtpSecure: "Usa la porta SSL/TLS predefinita per le connessioni SMTP"
|
||||
smtpSecureInfo: "Disabilita quando è attivo STARTTLS"
|
||||
testEmail: "Test di consegna email"
|
||||
wordMute: "Filtro parole"
|
||||
wordMute: "Filtro parole e lingua"
|
||||
instanceMute: "Server silenziati"
|
||||
userSaysSomething: "{name} ha detto qualcosa"
|
||||
makeActive: "Attiva"
|
||||
|
@ -1084,6 +1084,11 @@ _wordMute:
|
|||
soft: "Moderato"
|
||||
hard: "Severo"
|
||||
mutedNotes: "Post silenziati"
|
||||
muteLangsDescription2: 'Usa il codice lingua, esempio: en, fr, ja, zh.'
|
||||
lang: Lingua
|
||||
langDescription: Nascondi dalla timeline i post in quest'insieme di lingue.
|
||||
muteLangs: Lingue da non mostrare
|
||||
muteLangsDescription: Separa andando a capo o con spazi per la condizione OR.
|
||||
_theme:
|
||||
explore: "Esplora temi"
|
||||
install: "Installa un tema"
|
||||
|
@ -2162,3 +2167,6 @@ detectPostLanguage: Riconosci la lingua automaticamente e mostra il bottone per
|
|||
indexableDescription: Mostra i tuoi post pubblici tramite il sistema di ricerca
|
||||
indexable: Indicizzabile
|
||||
languageForTranslation: Linguaggio di traduzione dei post
|
||||
openServerInfo: Mostra informazioni sul server cliccando sul riquadro del server in
|
||||
un post
|
||||
vibrate: Abilita la vibrazione
|
||||
|
|
|
@ -66,7 +66,7 @@ import: "インポート"
|
|||
export: "エクスポート"
|
||||
files: "ファイル"
|
||||
download: "ダウンロード"
|
||||
driveFileDeleteConfirm: "ファイル「{name}」を削除しますか?これにより、このファイルが添付されている投稿も削除されます。"
|
||||
driveFileDeleteConfirm: "ファイル「{name}」を削除しますか?これにより、添付ファイルとして含まれているすべての投稿から削除されます。"
|
||||
unfollowConfirm: "{name}さんのフォローを解除しますか?"
|
||||
exportRequested: "エクスポートをリクエストしました。これには時間がかかる場合があります。エクスポートが終わると、「ドライブ」に追加されます。"
|
||||
importRequested: "インポートをリクエストしました。これには時間がかかる場合があります。"
|
||||
|
|
|
@ -120,6 +120,7 @@ rememberNoteVisibility: "Lembrar das configurações de visibilidade de notas"
|
|||
attachCancel: "Remover anexo"
|
||||
markAsSensitive: "Marcar como sensível"
|
||||
unmarkAsSensitive: "Desmarcar como sensível"
|
||||
clickToShowPatterns: "Clique para mostrar os padrões do módulo"
|
||||
enterFileName: "Digite o nome do ficheiro"
|
||||
mute: "Silenciar"
|
||||
unmute: "Dessilenciar"
|
||||
|
|
|
@ -285,7 +285,7 @@ pinnedPagesDescription: Bu sunucunun üst kısmına sabitlemek istediğiniz Sayf
|
|||
yollarını satır sonundan ayırarak girin.
|
||||
enableHcaptcha: hCaptcha'yı Aktif Et
|
||||
notifyAntenna: Yeni gönderileri bildir
|
||||
recentlyUpdatedUsers: En son aktif kullanıcılar
|
||||
recentlyUpdatedUsers: En son aktif olan kullanıcılar
|
||||
about: Hakkında
|
||||
twoStepAuthentication: İki-adımlı doğrulama
|
||||
securityKeyName: Anahtar ismi
|
||||
|
|
|
@ -12,7 +12,7 @@ ok: "好"
|
|||
gotIt: "知道了!"
|
||||
cancel: "取消"
|
||||
enterUsername: "输入用户名"
|
||||
renotedBy: "转发自 {user}"
|
||||
renotedBy: "{user} 转发了"
|
||||
noNotes: "没有帖子"
|
||||
noNotifications: "没有通知"
|
||||
instance: "服务器"
|
||||
|
@ -69,7 +69,7 @@ exportRequested: "导出请求已提交,这可能需要花一些时间,导
|
|||
importRequested: "导入请求已提交,这可能需要花一点时间。"
|
||||
lists: "列表"
|
||||
noLists: "列表为空"
|
||||
note: "帖子"
|
||||
note: "发帖"
|
||||
notes: "帖子"
|
||||
following: "关注中"
|
||||
followers: "关注者"
|
||||
|
@ -243,7 +243,7 @@ saved: "已保存"
|
|||
messaging: "聊天"
|
||||
upload: "本地上传"
|
||||
keepOriginalUploading: "保留原图"
|
||||
keepOriginalUploadingDescription: "上传图片时保留原始图片。如果关闭,会在上传时生成一张用于 web 发布的图片。"
|
||||
keepOriginalUploadingDescription: "上传图片时保留原始图片。如果关闭,会在上传时生成一张用于 Web 发布的图片。"
|
||||
fromDrive: "从网盘中"
|
||||
fromUrl: "从 URL"
|
||||
uploadFromUrl: "从 URL 上传"
|
||||
|
@ -486,15 +486,16 @@ hideThisNote: "隐藏这条帖子"
|
|||
showFeaturedNotesInTimeline: "在时间线上显示热门推荐"
|
||||
objectStorage: "对象存储"
|
||||
useObjectStorage: "使用对象存储"
|
||||
objectStorageBaseUrl: "Base URL"
|
||||
objectStorageBaseUrlDesc: "用于引用的 URL。如果您正在使用 CDN 或反向代理,请指定其 URL。\n例如S3:“https://<bucket>.s3.amazonaws.com”,GCS:“https://storage.googleapis.com/<bucket>”,其它同理。"
|
||||
objectStorageBaseUrl: "根 URL"
|
||||
objectStorageBaseUrlDesc: "用于引用的 URL。如果您正在使用 CDN 或反向代理,请指定其 URL。\n例如S3:\"https://<bucket>.s3.amazonaws.com\"\
|
||||
,GCS:\"https://storage.googleapis.com/<bucket>\",其它同理。"
|
||||
objectStorageBucket: "存储桶"
|
||||
objectStorageBucketDesc: "请指定使用的对象存储服务的存储桶名称。"
|
||||
objectStoragePrefix: "前缀"
|
||||
objectStoragePrefixDesc: "文件将存储在此前缀的目录下。"
|
||||
objectStorageEndpoint: "Endpoint"
|
||||
objectStorageEndpointDesc: "如果您使用 AWS S3 请留空。否则请根据您使用的服务商的说明来进行设置,指定 Endpoint 形式为
|
||||
\"<host>\" 或 \"<host>:<port>\"。"
|
||||
objectStorageEndpoint: "端点 (Endpoint)"
|
||||
objectStorageEndpointDesc: "如果您使用 AWS S3 请留空。否则请根据您使用的服务商的说明来进行设置,指定端点 (Endpoint)
|
||||
形式为 \"<host>\" 或 \"<host>:<port>\"。"
|
||||
objectStorageRegion: "可用区"
|
||||
objectStorageRegionDesc: "指定一个可用区,例如 \"xx-east-1\"。 如果您的对象存储服务没有可用区概念,请将其留空或填写 \"\
|
||||
us-east-1\"。\n对于 Cloudflare R2,可以填为 \"auto\"。"
|
||||
|
@ -502,7 +503,7 @@ objectStorageUseSSL: "使用 SSL"
|
|||
objectStorageUseSSLDesc: "如果不使用 HTTPS 进行 API 连接,请关闭"
|
||||
objectStorageUseProxy: "使用代理"
|
||||
objectStorageUseProxyDesc: "如果您不使用代理进行 API 连接,请将其关闭"
|
||||
objectStorageSetPublicRead: "上传时设置为 public-read"
|
||||
objectStorageSetPublicRead: "上传时设置为 \"public-read\""
|
||||
serverLogs: "服务器日志"
|
||||
deleteAll: "全部删除"
|
||||
showFixedPostForm: "在时间线顶部显示发帖框"
|
||||
|
@ -599,7 +600,7 @@ emptyToDisableSmtpAuth: "留空用户名和密码以禁用 SMTP 验证"
|
|||
smtpSecure: "在 SMTP 连接中使用隐式 SSL / TLS"
|
||||
smtpSecureInfo: "使用 STARTTLS 时关闭"
|
||||
testEmail: "邮件发送测试"
|
||||
wordMute: "文字过滤"
|
||||
wordMute: "文字和语言过滤"
|
||||
regexpError: "正则表达式错误"
|
||||
regexpErrorDescription: "{tab} 文字过滤的第 {line} 行的正则表达式有错误:"
|
||||
instanceMute: "服务器静音"
|
||||
|
@ -608,7 +609,7 @@ makeActive: "启用"
|
|||
display: "显示"
|
||||
copy: "复制"
|
||||
metrics: "指标"
|
||||
overview: "服务器概况"
|
||||
overview: "概况"
|
||||
logs: "日志"
|
||||
delayed: "滞后"
|
||||
database: "数据库"
|
||||
|
@ -689,8 +690,8 @@ useSystemFont: "使用系统默认字体"
|
|||
clips: "便签"
|
||||
experimentalFeatures: "实验性功能"
|
||||
developer: "开发者"
|
||||
makeExplorable: "使账号在“发现”中可见"
|
||||
makeExplorableDescription: "关闭时,账号不会显示在\"发现\"中。"
|
||||
makeExplorable: "使账号在「发现」中可见"
|
||||
makeExplorableDescription: "关闭时,账号不会显示在「发现」中。"
|
||||
showGapBetweenNotesInTimeline: "时间线上的帖子分开显示"
|
||||
duplicate: "复制"
|
||||
left: "左"
|
||||
|
@ -745,7 +746,7 @@ unlikeConfirm: "取消赞?"
|
|||
fullView: "全屏"
|
||||
quitFullView: "退出全屏"
|
||||
addDescription: "添加描述"
|
||||
userPagePinTip: "在帖子的菜单中选择“置顶”,即可显示该条帖子。"
|
||||
userPagePinTip: "在帖子的菜单中选择「置顶」,即可在此显示该条帖子。"
|
||||
notSpecifiedMentionWarning: "有未指定的提及"
|
||||
info: "关于"
|
||||
userInfo: "用户信息"
|
||||
|
@ -805,7 +806,7 @@ accountDeletionInProgress: "正在删除账号"
|
|||
usernameInfo: "在服务器上唯一标识您的账号的名称。您可以使用字母 (a ~ z, A ~ Z)、数字 (0 ~ 9) 和下划线 (_)。用户名以后不能更改。"
|
||||
aiChanMode: "小蓝模式"
|
||||
keepCw: "保留内容警告"
|
||||
pubSub: "推送 (Pub)/订阅 (Sub) 账号"
|
||||
pubSub: "推送 (Pub) / 订阅 (Sub) 账号"
|
||||
lastCommunication: "最近通信"
|
||||
resolved: "已解决"
|
||||
unresolved: "未解决"
|
||||
|
@ -822,8 +823,8 @@ makeReactionsPublicDescription: "将您发表过的回应设置成公开可见
|
|||
classic: "居中"
|
||||
muteThread: "静音帖子串"
|
||||
unmuteThread: "取消静音帖子串"
|
||||
ffVisibility: "关注/关注者 可见性"
|
||||
ffVisibilityDescription: "您可以设置谁可以看到您的关注/关注者信息。"
|
||||
ffVisibility: "关注 / 关注者可见性"
|
||||
ffVisibilityDescription: "您可以设置谁可以看到您的关注 / 关注者信息。"
|
||||
continueThread: "查看更多帖子"
|
||||
deleteAccountConfirm: "这将不可逆转地删除账号,是否继续?"
|
||||
incorrectPassword: "密码错误。"
|
||||
|
@ -921,7 +922,7 @@ _emailUnavailable:
|
|||
_ffVisibility:
|
||||
public: "公开"
|
||||
followers: "仅对关注者可见"
|
||||
private: "私信"
|
||||
private: "私密"
|
||||
_signup:
|
||||
almostThere: "即将完成"
|
||||
emailAddressInfo: "请输入您所使用的电子邮件地址,它不会公开显示。"
|
||||
|
@ -1113,7 +1114,7 @@ _wordMute:
|
|||
muteLangs: "过滤语言"
|
||||
muteWordsDescription: "AND 条件用空格分隔,OR 条件用换行符分隔。"
|
||||
muteWordsDescription2: "将关键字用斜线括起来表示正则表达式。"
|
||||
muteLangsDescription: "OR 条件用空格,换行符分隔"
|
||||
muteLangsDescription: "OR 条件用空格或换行符分隔。"
|
||||
muteLangsDescription2: "使用语言代码。例: en, fr, ja, zh."
|
||||
softDescription: "隐藏时间线中指定条件的帖子。"
|
||||
langDescription: "从时间线中隐藏与设置语言匹配的帖子。"
|
||||
|
@ -1236,11 +1237,11 @@ _tutorial:
|
|||
step4_2: "对于第一条帖子,可以做一个 {introduction} 或一个简单的 \"hello world!\""
|
||||
step5_1: "时间线,无处不在的时间线!"
|
||||
step5_2: "您的服务器已启用 {timelines} 种不同的时间线。"
|
||||
step5_3: "主页 {icon} 时间线是您可以看到您关注账号的帖子的时间线。"
|
||||
step5_3: "首页{icon}时间线是您可以看到您关注账号的帖子的时间线。"
|
||||
step5_4: "本地{icon}时间线是您可以看到此服务器上其它用户的帖子的时间线。"
|
||||
step5_5: "社交{icon}时间线是主页和本地时间线的结合。"
|
||||
step5_6: "推荐{icon}时间线是您可以看到管理员推荐服务器的帖子的时间线。"
|
||||
step5_7: "全球{icon}时间线是您可以看到来自其它所有互联服务器的帖子的时间线。"
|
||||
step5_7: "全局{icon}时间线是您可以看到来自其它所有互联服务器的帖子的时间线。"
|
||||
step6_1: "那么,这里是什么地方?"
|
||||
step6_2: "好吧,您不只是加入 Firefish。您已经加入了 Fediverse 的一个门户,这是一个由成千上万台服务器组成的互联网络。"
|
||||
step6_3: "每个服务器的工作方式不同,并不是所有的服务器都运行 Firefish。但这个服务器是的! 这有点复杂,但您很快就会明白的。"
|
||||
|
@ -1839,8 +1840,8 @@ customMOTD: 自定义 MOTD(启动屏幕消息)
|
|||
sendPushNotificationReadMessageCaption: 会短暂显示 "{emptyPushNotificationMessage}" 的通知,如果启用,可能会增加您的设备的耗电量。
|
||||
adminCustomCssWarn: 仅当您知道此设置的作用时才应使用它。输入不正确的值可能会导致每个人的客户端停止正常运行。请在用户设置中进行测试来确保您的 CSS
|
||||
正常工作。
|
||||
customMOTDDescription: 自定义 MOTD(启动屏幕)消息,一行一个,每次用户加载/刷新页面时都会随机显示。
|
||||
customSplashIconsDescription: 用换行符隔开的自定义启动屏幕图标的 URL,在用户每次加载/重新载入页面时随机显示。请确保图片是在一个静态的
|
||||
customMOTDDescription: 自定义 MOTD(启动屏幕)消息,一行一个,每次用户加载 / 重新加载页面时都会随机显示。
|
||||
customSplashIconsDescription: 用换行符隔开的自定义启动屏幕图标的 URL,在用户每次加载 / 重新加载页面时随机显示。请确保图片是在一个静态的
|
||||
URL 上,最好全部调整为 192x192 的大小。
|
||||
recommendedInstancesDescription: 推荐的服务器一行一个,它们将出现在推荐时间线中。
|
||||
splash: 启动画面
|
||||
|
@ -1863,7 +1864,7 @@ customSplashIcons: 自定义启动屏幕图标(urls)
|
|||
alt: 替代文字
|
||||
pushNotificationNotSupported: 您的浏览器或者服务器不支持推送通知
|
||||
showAds: 显示社区横幅
|
||||
enterSendsMessage: 按回车键发送信息(关闭则是 Ctrl + Retun 发送)
|
||||
enterSendsMessage: 按回车键发送信息(关闭则是 Ctrl + Return 发送)
|
||||
recommendedInstances: 推荐服务器
|
||||
updateAvailable: 可能有可用更新!
|
||||
swipeOnMobile: 允许在页面之间滑动
|
||||
|
@ -1976,7 +1977,7 @@ confirm: 确认
|
|||
importZip: 导入 ZIP
|
||||
exportZip: 导出 ZIP
|
||||
emojiPackCreator: 表情包创建工具
|
||||
objectStorageS3ForcePathStyleDesc: 打开此选项可构建格式为 's3.amazonaws.com/<bucket>/' 而非 '<bucket>.s3.amazonaws.com'
|
||||
objectStorageS3ForcePathStyleDesc: 打开此选项可构建格式为 "s3.amazonaws.com/<bucket>/" 而非 "<bucket>.s3.amazonaws.com"
|
||||
的端点 URL。
|
||||
objectStorageS3ForcePathStyle: 使用基于路径的端点 URL
|
||||
delete2fa: 禁用 2FA
|
||||
|
@ -1989,3 +1990,5 @@ detectPostLanguage: 自动检测语言,并显示外文帖子的翻译按钮
|
|||
indexableDescription: 允许内置搜索显示您的公开帖子
|
||||
indexable: 可索引的
|
||||
languageForTranslation: 帖子翻译语言
|
||||
vibrate: 播放振动
|
||||
openServerInfo: 点击帖子上的服务器滚动条时显示服务器信息
|
||||
|
|
|
@ -12,7 +12,7 @@ ok: "OK"
|
|||
gotIt: "知道了!"
|
||||
cancel: "取消"
|
||||
enterUsername: "輸入使用者名稱"
|
||||
renotedBy: "{user} 轉傳了"
|
||||
renotedBy: "{user} 轉發了"
|
||||
noNotes: "無貼文"
|
||||
noNotifications: "沒有通知"
|
||||
instance: "伺服器"
|
||||
|
@ -292,7 +292,7 @@ inputNewFileName: "輸入檔案名稱"
|
|||
inputNewDescription: "請輸入新標題"
|
||||
inputNewFolderName: "輸入新資料夾的名稱"
|
||||
circularReferenceFolder: "目標文件夾是您要移動的文件夾的子文件夾。"
|
||||
hasChildFilesOrFolders: "此文件夾不是空的,無法刪除。"
|
||||
hasChildFilesOrFolders: "此資料夾不是空的,無法刪除。"
|
||||
copyUrl: "複製網址"
|
||||
rename: "重新命名"
|
||||
avatar: "大頭貼"
|
||||
|
@ -325,7 +325,7 @@ connectService: "己連結"
|
|||
disconnectService: "己斷開"
|
||||
enableLocalTimeline: "開啟本地時間線"
|
||||
enableGlobalTimeline: "啟用公開時間線"
|
||||
disablingTimelinesInfo: "即使您關閉了時間線功能,管理員和版主始終可以訪問所有的時間線。"
|
||||
disablingTimelinesInfo: "即使您關閉了時間線功能,管理員和板主仍可訪問所有的時間線。"
|
||||
registration: "註冊"
|
||||
enableRegistration: "開啟新使用者註冊"
|
||||
invite: "邀請"
|
||||
|
@ -773,7 +773,7 @@ gallery: "相簿"
|
|||
recentPosts: "最新貼文"
|
||||
popularPosts: "熱門的貼文"
|
||||
shareWithNote: "在貼文中分享"
|
||||
ads: "廣告"
|
||||
ads: "社群橫幅"
|
||||
expiration: "期限"
|
||||
memo: "備忘錄"
|
||||
priority: "優先級"
|
||||
|
@ -801,7 +801,7 @@ translatedFrom: "從 {x} 翻譯"
|
|||
accountDeletionInProgress: "正在刪除帳戶"
|
||||
usernameInfo: "在伺服器上您的帳戶是唯一的識別名稱。您可以使用字母 (a ~ z, A ~ Z)、數字 (0 ~ 9) 和下底線 (_)。之後帳戶名是不能更改的。"
|
||||
aiChanMode: "小藍模式"
|
||||
keepCw: "保持CW"
|
||||
keepCw: "保持內容警告"
|
||||
pubSub: "Pub/Sub 帳戶"
|
||||
lastCommunication: "最近的通信"
|
||||
resolved: "已解決"
|
||||
|
@ -1067,7 +1067,7 @@ _mfm:
|
|||
position: 位置
|
||||
alwaysPlay: 自動播放所有MFM動畫
|
||||
positionDescription: 按指定數量移動內容。
|
||||
advancedDescription: 如果禁用,則僅允許基本標記,除非正在播放 MFM 動畫
|
||||
advancedDescription: 如果停用,僅顯示基礎MFM及正在播放的MFM動畫
|
||||
advanced: 高級MFM
|
||||
fade: 淡出
|
||||
foreground: 文字顏色
|
||||
|
@ -1114,6 +1114,11 @@ _wordMute:
|
|||
soft: "軟性靜音"
|
||||
hard: "硬性靜音"
|
||||
mutedNotes: "已靜音的貼文"
|
||||
muteLangsDescription2: '使用語言代碼。例: en, fr, ja, zh.'
|
||||
lang: 語言
|
||||
langDescription: 將指定語言的貼文從時間線中隱藏。
|
||||
muteLangs: 被靜音的語言
|
||||
muteLangsDescription: OR條件以空格或換行進行分隔。
|
||||
_instanceMute:
|
||||
instanceMuteDescription: "包括對被靜音伺服器上的用戶的回覆,被設定的伺服器上所有貼文及轉發都會被靜音。"
|
||||
instanceMuteDescription2: "設定時以換行進行分隔"
|
||||
|
@ -1355,9 +1360,9 @@ _cw:
|
|||
files: "{count} 個檔案"
|
||||
_poll:
|
||||
noOnlyOneChoice: "至少需要兩個選項"
|
||||
choiceN: "選擇{n}"
|
||||
choiceN: "選項{n}"
|
||||
noMore: "沒辦法再添加選項了"
|
||||
canMultipleVote: "可以多次投票"
|
||||
canMultipleVote: "允許複選"
|
||||
expiration: "期限"
|
||||
infinite: "無期限"
|
||||
at: "結束時間"
|
||||
|
@ -1366,7 +1371,7 @@ _poll:
|
|||
deadlineTime: "小時"
|
||||
duration: "時長"
|
||||
votesCount: "{n}票"
|
||||
totalVotes: "一共{n}票"
|
||||
totalVotes: "總計{n}票"
|
||||
vote: "投票"
|
||||
showResult: "顯示結果"
|
||||
voted: "已投票"
|
||||
|
@ -1777,6 +1782,7 @@ _notification:
|
|||
reply: "回覆"
|
||||
renote: "轉發"
|
||||
reacted: 對您的貼文做出了反應
|
||||
renoted: 轉發了您的貼文
|
||||
_deck:
|
||||
alwaysShowMainColumn: "總是顯示主欄"
|
||||
columnAlign: "對齊欄位"
|
||||
|
@ -1823,13 +1829,13 @@ adminCustomCssWarn: 除非你知道它的作用,否則請不要使用此設定
|
|||
CSS 正常工作。
|
||||
showUpdates: Firefish 更新時顯示彈出視窗
|
||||
recommendedInstances: 建議的伺服器
|
||||
caption: 自動字幕
|
||||
caption: 自動加上替代文字(alt)
|
||||
enterSendsMessage: 在 Messaging 中按 Return 發送消息 (如關閉則是 Ctrl + Return)
|
||||
migrationConfirm: "您確定要將你的帳戶遷移到 {account} 嗎? 一旦這樣做,你將無法復原,而你將無法再次正常使用您的帳戶。\n另外,請確保你已將此當前帳戶設置為您要遷移的帳戶。"
|
||||
customSplashIconsDescription: 每次用戶加載/重新加載頁面時,以換行符號分隔的自定啟動畫面圖標的網址將隨機顯示。請確保圖片位於靜態網址上,最好所有圖片解析度調整為
|
||||
192x192。
|
||||
accountMoved: '該使用者已遷移至新帳戶:'
|
||||
showAds: 顯示廣告
|
||||
showAds: 顯示社群橫幅
|
||||
noThankYou: 不用了,謝謝
|
||||
selectInstance: 選擇伺服器
|
||||
enableRecommendedTimeline: 啟用推薦時間線
|
||||
|
@ -1863,9 +1869,10 @@ silencedInstances: 已靜音的伺服器
|
|||
silenced: 已靜音
|
||||
_experiments:
|
||||
title: 試驗功能
|
||||
enablePostImports: 啟用匯入貼文的功能
|
||||
findOtherInstance: 找找另一個伺服器
|
||||
noGraze: 瀏覽器擴展 "Graze for Mastodon" 會與Firefish發生衝突,請停用該擴展。
|
||||
userSaysSomethingReasonRenote: '{name} 轉傳了包含 {reason} 的貼文'
|
||||
userSaysSomethingReasonRenote: '{name} 轉發了包含 {reason} 的貼文'
|
||||
pushNotificationNotSupported: 你的瀏覽器或伺服器不支援推送通知
|
||||
accessibility: 輔助功能
|
||||
userSaysSomethingReasonReply: '{name} 回覆了包含 {reason} 的貼文'
|
||||
|
@ -1909,7 +1916,7 @@ channelFederationWarn: 頻道功能尚未與聯邦宇宙連動
|
|||
swipeOnMobile: 允許以滑動在頁面之間切換
|
||||
sendPushNotificationReadMessage: 閱讀相關通知或消息後刪除推送通知
|
||||
image: 圖片
|
||||
seperateRenoteQuote: 分別獨立的轉傳及引用按鈕
|
||||
seperateRenoteQuote: 分開轉發及引用的按鈕
|
||||
clipsDesc: 摘錄就像一個可以分享的書籤。 你可以從每個貼文的菜單創建新摘錄或將貼文加入已有的摘錄。
|
||||
noteId: 貼文 ID
|
||||
sendModMail: 發送審核通知
|
||||
|
@ -1919,7 +1926,7 @@ reactionPickerSkinTone: 首選表情符號膚色
|
|||
indexFromDescription: 留空以索引每個貼文
|
||||
preventAiLearning: 防止 AI 機器人抓取
|
||||
preventAiLearningDescription: 請求第三方 AI 語言模型不要研究您上傳的內容,例如貼文和圖像。
|
||||
indexFrom: 從貼文 ID 開始的索引
|
||||
indexFrom: 建立此貼文ID以後的索引
|
||||
isLocked: 該帳戶已獲得以下批准
|
||||
isModerator: 板主
|
||||
isAdmin: 管理員
|
||||
|
@ -1927,7 +1934,7 @@ isPatron: Firefish 項目贊助者
|
|||
silencedWarning: 顯示此頁面是因為這些使用者來自您伺服器管理員已靜音的伺服器,因此他們可能是垃圾訊息。
|
||||
signupsDisabled: 該伺服器上的註冊當前已被禁用,但您隨時可以在另一台伺服器上註冊!或是您有該伺服器的邀請碼,請在下面輸入。
|
||||
showPopup: 通過彈出式視窗通知用戶
|
||||
showWithSparkles: 閃閃發光的顯示
|
||||
showWithSparkles: 讓標題閃閃發光
|
||||
youHaveUnreadAnnouncements: 您有未讀的公告
|
||||
donationLink: 連結到贊助頁面
|
||||
neverShow: 不再顯示
|
||||
|
@ -1962,3 +1969,11 @@ emojiPackCreator: 表情包的作者
|
|||
importZip: 匯入ZIP
|
||||
delete2fa: 停用二階段認證(2FA)
|
||||
confirm: 確認
|
||||
deletePasskeysConfirm: 此帳號的所有通行密鑰及安全密鑰將被完全刪除。此動作無法復原,是否繼續?
|
||||
deletePasskeys: 刪除通行密鑰
|
||||
detectPostLanguage: 自動判定貼文的語言,並在外文貼文顯示翻譯按鈕
|
||||
indexableDescription: 允許內建搜尋引擎顯示您的公開貼文
|
||||
addRe: 在回覆有內容警告的貼文時,在標題前面加上 "re:"
|
||||
vibrate: 播放振動
|
||||
openServerInfo: 點擊貼文中的伺服器名稱以顯示伺服器資訊
|
||||
languageForTranslation: 貼文翻譯語言
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "firefish",
|
||||
"version": "1.0.5-dev14",
|
||||
"version": "1.0.5-dev17",
|
||||
"codename": "aqua",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
@ -9,8 +9,8 @@
|
|||
"packageManager": "pnpm@8.7.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"rebuild": "pnpm run clean && ./scripts/build-greet.sh && pnpm -r --parallel run build && pnpm run gulp",
|
||||
"build": "./scripts/build-greet.sh && pnpm -r --parallel run build && pnpm run gulp",
|
||||
"rebuild": "pnpm run clean && ./scripts/build-greet.sh && pnpm --filter !sw -r --parallel run build && pnpm --filter sw run build && pnpm run gulp",
|
||||
"build": "./scripts/build-greet.sh && pnpm --filter !sw -r --parallel run build && pnpm --filter sw run build && pnpm run gulp",
|
||||
"start": "pnpm --filter backend run start",
|
||||
"start:test": "pnpm --filter backend run start:test",
|
||||
"init": "pnpm run migrate",
|
||||
|
|
BIN
packages/backend/assets/badges/error.avif
Normal file
BIN
packages/backend/assets/badges/error.avif
Normal file
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 40 KiB |
BIN
packages/backend/assets/badges/info.avif
Normal file
BIN
packages/backend/assets/badges/info.avif
Normal file
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 9.9 KiB |
BIN
packages/backend/assets/badges/not-found.avif
Normal file
BIN
packages/backend/assets/badges/not-found.avif
Normal file
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 9.1 KiB |
|
@ -220,7 +220,7 @@ export class Init1000000000000 {
|
|||
`CREATE INDEX "IDX_3c601b70a1066d2c8b517094cb" ON "notification" ("notifieeId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "meta" ("id" character varying(32) NOT NULL, "name" character varying(128), "description" character varying(1024), "maintainerName" character varying(128), "maintainerEmail" character varying(128), "announcements" jsonb NOT NULL DEFAULT '[]', "disableRegistration" boolean NOT NULL DEFAULT false, "disableLocalTimeline" boolean NOT NULL DEFAULT false, "disableGlobalTimeline" boolean NOT NULL DEFAULT false, "enableEmojiReaction" boolean NOT NULL DEFAULT true, "useStarForReactionFallback" boolean NOT NULL DEFAULT false, "langs" character varying(64) array NOT NULL DEFAULT '{}'::varchar[], "hiddenTags" character varying(256) array NOT NULL DEFAULT '{}'::varchar[], "blockedHosts" character varying(256) array NOT NULL DEFAULT '{}'::varchar[], "mascotImageUrl" character varying(512) DEFAULT '/static-assets/badges/info.png', "bannerUrl" character varying(512), "errorImageUrl" character varying(512) DEFAULT '/static-assets/badges/error.png', "iconUrl" character varying(512), "cacheRemoteFiles" boolean NOT NULL DEFAULT false, "proxyAccount" character varying(128), "enableRecaptcha" boolean NOT NULL DEFAULT false, "recaptchaSiteKey" character varying(64), "recaptchaSecretKey" character varying(64), "localDriveCapacityMb" integer NOT NULL DEFAULT 1024, "remoteDriveCapacityMb" integer NOT NULL DEFAULT 32, "maxNoteTextLength" integer NOT NULL DEFAULT 500, "summalyProxy" character varying(128), "enableEmail" boolean NOT NULL DEFAULT false, "email" character varying(128), "smtpSecure" boolean NOT NULL DEFAULT false, "smtpHost" character varying(128), "smtpPort" integer, "smtpUser" character varying(128), "smtpPass" character varying(128), "enableServiceWorker" boolean NOT NULL DEFAULT false, "swPublicKey" character varying(128), "swPrivateKey" character varying(128), "enableTwitterIntegration" boolean NOT NULL DEFAULT false, "twitterConsumerKey" character varying(128), "twitterConsumerSecret" character varying(128), "enableGithubIntegration" boolean NOT NULL DEFAULT false, "githubClientId" character varying(128), "githubClientSecret" character varying(128), "enableDiscordIntegration" boolean NOT NULL DEFAULT false, "discordClientId" character varying(128), "discordClientSecret" character varying(128), CONSTRAINT "PK_c4c17a6c2bd7651338b60fc590b" PRIMARY KEY ("id"))`,
|
||||
`CREATE TABLE "meta" ("id" character varying(32) NOT NULL, "name" character varying(128), "description" character varying(1024), "maintainerName" character varying(128), "maintainerEmail" character varying(128), "announcements" jsonb NOT NULL DEFAULT '[]', "disableRegistration" boolean NOT NULL DEFAULT false, "disableLocalTimeline" boolean NOT NULL DEFAULT false, "disableGlobalTimeline" boolean NOT NULL DEFAULT false, "enableEmojiReaction" boolean NOT NULL DEFAULT true, "useStarForReactionFallback" boolean NOT NULL DEFAULT false, "langs" character varying(64) array NOT NULL DEFAULT '{}'::varchar[], "hiddenTags" character varying(256) array NOT NULL DEFAULT '{}'::varchar[], "blockedHosts" character varying(256) array NOT NULL DEFAULT '{}'::varchar[], "mascotImageUrl" character varying(512) DEFAULT '/static-assets/badges/info.avif', "bannerUrl" character varying(512), "errorImageUrl" character varying(512) DEFAULT '/static-assets/badges/error.avif', "iconUrl" character varying(512), "cacheRemoteFiles" boolean NOT NULL DEFAULT false, "proxyAccount" character varying(128), "enableRecaptcha" boolean NOT NULL DEFAULT false, "recaptchaSiteKey" character varying(64), "recaptchaSecretKey" character varying(64), "localDriveCapacityMb" integer NOT NULL DEFAULT 1024, "remoteDriveCapacityMb" integer NOT NULL DEFAULT 32, "maxNoteTextLength" integer NOT NULL DEFAULT 500, "summalyProxy" character varying(128), "enableEmail" boolean NOT NULL DEFAULT false, "email" character varying(128), "smtpSecure" boolean NOT NULL DEFAULT false, "smtpHost" character varying(128), "smtpPort" integer, "smtpUser" character varying(128), "smtpPass" character varying(128), "enableServiceWorker" boolean NOT NULL DEFAULT false, "swPublicKey" character varying(128), "swPrivateKey" character varying(128), "enableTwitterIntegration" boolean NOT NULL DEFAULT false, "twitterConsumerKey" character varying(128), "twitterConsumerSecret" character varying(128), "enableGithubIntegration" boolean NOT NULL DEFAULT false, "githubClientId" character varying(128), "githubClientSecret" character varying(128), "enableDiscordIntegration" boolean NOT NULL DEFAULT false, "discordClientId" character varying(128), "discordClientSecret" character varying(128), CONSTRAINT "PK_c4c17a6c2bd7651338b60fc590b" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "following" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "followeeId" character varying(32) NOT NULL, "followerId" character varying(32) NOT NULL, "followerHost" character varying(128), "followerInbox" character varying(512), "followerSharedInbox" character varying(512), "followeeHost" character varying(128), "followeeInbox" character varying(512), "followeeSharedInbox" character varying(512), CONSTRAINT "PK_c76c6e044bdf76ecf8bfb82a645" PRIMARY KEY ("id"))`,
|
||||
|
|
13
packages/backend/migration/1695334243217-add-post-lang.js
Normal file
13
packages/backend/migration/1695334243217-add-post-lang.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
export class AddPostLang1695334243217 {
|
||||
name = "AddPostLang1695334243217";
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "note" ADD "lang" character varying(10)`,
|
||||
);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "lang"`);
|
||||
}
|
||||
}
|
|
@ -143,7 +143,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@swc/cli": "^0.1.62",
|
||||
"@swc/core": "1.3.82",
|
||||
"@swc/core": "1.3.78",
|
||||
"@types/adm-zip": "^0.5.0",
|
||||
"@types/bcryptjs": "2.4.2",
|
||||
"@types/escape-regexp": "0.0.1",
|
||||
|
|
|
@ -68,6 +68,15 @@ export const FILE_TYPE_BROWSERSAFE = [
|
|||
"audio/x-flac",
|
||||
"audio/flac",
|
||||
"audio/vnd.wave",
|
||||
|
||||
"audio/mod",
|
||||
"audio/x-mod",
|
||||
"audio/s3m",
|
||||
"audio/x-s3m",
|
||||
"audio/xm",
|
||||
"audio/x-xm",
|
||||
"audio/it",
|
||||
"audio/x-it",
|
||||
];
|
||||
/*
|
||||
https://github.com/sindresorhus/file-type/blob/main/supported.js
|
||||
|
|
|
@ -110,9 +110,8 @@ export function fromHtml(html: string, hashtagNames?: string[]): string {
|
|||
}
|
||||
|
||||
case "h1": {
|
||||
text += "【";
|
||||
appendChildren(node.childNodes);
|
||||
text += "】\n";
|
||||
text += "\n";
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
export default function (reaction: string): string {
|
||||
switch (reaction) {
|
||||
case "like":
|
||||
return "👍";
|
||||
case "love":
|
||||
return "❤️";
|
||||
case "laugh":
|
||||
return "😆";
|
||||
case "hmm":
|
||||
return "🤔";
|
||||
case "surprise":
|
||||
return "😮";
|
||||
case "congrats":
|
||||
return "🎉";
|
||||
case "angry":
|
||||
return "💢";
|
||||
case "confused":
|
||||
return "😥";
|
||||
case "rip":
|
||||
return "😇";
|
||||
case "pudding":
|
||||
return "🍮";
|
||||
case "star":
|
||||
return "⭐";
|
||||
default:
|
||||
return reaction;
|
||||
}
|
||||
}
|
|
@ -4,58 +4,22 @@ import { Emojis } from "@/models/index.js";
|
|||
import { toPunyNullable } from "./convert-host.js";
|
||||
import { IsNull } from "typeorm";
|
||||
|
||||
const legacies = new Map([
|
||||
["like", "👍"],
|
||||
["love", "❤️"],
|
||||
["laugh", "😆"],
|
||||
["hmm", "🤔"],
|
||||
["surprise", "😮"],
|
||||
["congrats", "🎉"],
|
||||
["angry", "💢"],
|
||||
["confused", "😥"],
|
||||
["rip", "😇"],
|
||||
["pudding", "🍮"],
|
||||
["star", "⭐"],
|
||||
]);
|
||||
|
||||
export async function getFallbackReaction() {
|
||||
const meta = await fetchMeta();
|
||||
return meta.defaultReaction;
|
||||
}
|
||||
|
||||
export function convertLegacyReactions(reactions: Record<string, number>) {
|
||||
const _reactions = new Map();
|
||||
const decodedReactions = new Map();
|
||||
export function convertReactions(reactions: Record<string, number>) {
|
||||
const result = new Map();
|
||||
|
||||
for (const reaction in reactions) {
|
||||
if (reactions[reaction] <= 0) continue;
|
||||
|
||||
let decodedReaction;
|
||||
if (decodedReactions.has(reaction)) {
|
||||
decodedReaction = decodedReactions.get(reaction);
|
||||
} else {
|
||||
decodedReaction = decodeReaction(reaction);
|
||||
decodedReactions.set(reaction, decodedReaction);
|
||||
}
|
||||
|
||||
let emoji = legacies.get(decodedReaction.reaction);
|
||||
if (emoji) {
|
||||
_reactions.set(emoji, (_reactions.get(emoji) || 0) + reactions[reaction]);
|
||||
} else {
|
||||
_reactions.set(
|
||||
reaction,
|
||||
(_reactions.get(reaction) || 0) + reactions[reaction],
|
||||
);
|
||||
}
|
||||
const decoded = decodeReaction(reaction).reaction;
|
||||
result.set(decoded, (result.get(decoded) || 0) + reactions[reaction]);
|
||||
}
|
||||
|
||||
const _reactions2 = new Map();
|
||||
for (const [reaction, count] of _reactions) {
|
||||
const decodedReaction = decodedReactions.get(reaction);
|
||||
_reactions2.set(decodedReaction.reaction, count);
|
||||
}
|
||||
|
||||
return Object.fromEntries(_reactions2);
|
||||
return Object.fromEntries(result);
|
||||
}
|
||||
|
||||
export async function toDbReaction(
|
||||
|
@ -66,9 +30,7 @@ export async function toDbReaction(
|
|||
|
||||
reacterHost = toPunyNullable(reacterHost);
|
||||
|
||||
// Convert string-type reactions to unicode
|
||||
const emoji = legacies.get(reaction) || (reaction === "♥️" ? "❤️" : null);
|
||||
if (emoji) return emoji;
|
||||
if (reaction === "♥️") return "❤️";
|
||||
|
||||
// Allow unicode reactions
|
||||
const match = emojiRegex.exec(reaction);
|
||||
|
@ -128,9 +90,3 @@ export function decodeReaction(str: string): DecodedReaction {
|
|||
host: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function convertLegacyReaction(reaction: string): string {
|
||||
const decoded = decodeReaction(reaction).reaction;
|
||||
if (legacies.has(decoded)) return legacies.get(decoded)!;
|
||||
return decoded;
|
||||
}
|
||||
|
|
|
@ -162,7 +162,7 @@ export class Meta {
|
|||
@Column("varchar", {
|
||||
length: 512,
|
||||
nullable: true,
|
||||
default: "/static-assets/badges/info.png",
|
||||
default: "/static-assets/badges/info.avif",
|
||||
})
|
||||
public mascotImageUrl: string | null;
|
||||
|
||||
|
@ -187,7 +187,7 @@ export class Meta {
|
|||
@Column("varchar", {
|
||||
length: 512,
|
||||
nullable: true,
|
||||
default: "/static-assets/badges/error.png",
|
||||
default: "/static-assets/badges/error.avif",
|
||||
})
|
||||
public errorImageUrl: string | null;
|
||||
|
||||
|
|
|
@ -66,6 +66,12 @@ export class Note {
|
|||
})
|
||||
public text: string | null;
|
||||
|
||||
@Column("varchar", {
|
||||
length: 10,
|
||||
nullable: true,
|
||||
})
|
||||
public lang: string | null;
|
||||
|
||||
@Column("varchar", {
|
||||
length: 256,
|
||||
nullable: true,
|
||||
|
|
|
@ -2,7 +2,7 @@ import { db } from "@/db/postgre.js";
|
|||
import { NoteReaction } from "@/models/entities/note-reaction.js";
|
||||
import { Notes, Users } from "../index.js";
|
||||
import type { Packed } from "@/misc/schema.js";
|
||||
import { convertLegacyReaction } from "@/misc/reaction-lib.js";
|
||||
import { decodeReaction } from "@/misc/reaction-lib.js";
|
||||
import type { User } from "@/models/entities/user.js";
|
||||
|
||||
export const NoteReactionRepository = db.getRepository(NoteReaction).extend({
|
||||
|
@ -27,7 +27,7 @@ export const NoteReactionRepository = db.getRepository(NoteReaction).extend({
|
|||
id: reaction.id,
|
||||
createdAt: reaction.createdAt.toISOString(),
|
||||
user: await Users.pack(reaction.user ?? reaction.userId, me),
|
||||
type: convertLegacyReaction(reaction.reaction),
|
||||
type: decodeReaction(reaction.reaction).reaction,
|
||||
...(opts.withNote
|
||||
? {
|
||||
// may throw error
|
||||
|
@ -41,7 +41,7 @@ export const NoteReactionRepository = db.getRepository(NoteReaction).extend({
|
|||
src: NoteReaction[],
|
||||
me?: { id: User["id"] } | null | undefined,
|
||||
options?: {
|
||||
withNote: booleam;
|
||||
withNote: boolean;
|
||||
},
|
||||
): Promise<Packed<"NoteReaction">[]> {
|
||||
const reactions = await Promise.allSettled(
|
||||
|
|
|
@ -14,11 +14,7 @@ import {
|
|||
import type { Packed } from "@/misc/schema.js";
|
||||
import { nyaize } from "@/misc/nyaize.js";
|
||||
import { awaitAll } from "@/prelude/await-all.js";
|
||||
import {
|
||||
convertLegacyReaction,
|
||||
convertLegacyReactions,
|
||||
decodeReaction,
|
||||
} from "@/misc/reaction-lib.js";
|
||||
import { convertReactions, decodeReaction } from "@/misc/reaction-lib.js";
|
||||
import type { NoteReaction } from "@/models/entities/note-reaction.js";
|
||||
import {
|
||||
aggregateNoteEmojis,
|
||||
|
@ -27,7 +23,7 @@ import {
|
|||
} from "@/misc/populate-emojis.js";
|
||||
import { db } from "@/db/postgre.js";
|
||||
import { IdentifiableError } from "@/misc/identifiable-error.js";
|
||||
import { detect as detectLanguage_ } from "tinyld";
|
||||
import { detect as detectLanguage } from "tinyld";
|
||||
|
||||
export async function populatePoll(note: Note, meId: User["id"] | null) {
|
||||
const poll = await Polls.findOneByOrFail({ noteId: note.id });
|
||||
|
@ -77,7 +73,7 @@ async function populateMyReaction(
|
|||
if (_hint_?.myReactions) {
|
||||
const reaction = _hint_.myReactions.get(note.id);
|
||||
if (reaction) {
|
||||
return convertLegacyReaction(reaction.reaction);
|
||||
return decodeReaction(reaction.reaction).reaction;
|
||||
} else if (reaction === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
@ -90,7 +86,7 @@ async function populateMyReaction(
|
|||
});
|
||||
|
||||
if (reaction) {
|
||||
return convertLegacyReaction(reaction.reaction);
|
||||
return decodeReaction(reaction.reaction).reaction;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
@ -203,8 +199,6 @@ export const NoteRepository = db.getRepository(Note).extend({
|
|||
host,
|
||||
);
|
||||
|
||||
const lang =
|
||||
detectLanguage_(`${note.cw ?? ""}\n${note.text ?? ""}`) ?? "unknown";
|
||||
const reactionEmoji = await populateEmojis(reactionEmojiNames, host);
|
||||
const packed: Packed<"Note"> = await awaitAll({
|
||||
id: note.id,
|
||||
|
@ -221,7 +215,7 @@ export const NoteRepository = db.getRepository(Note).extend({
|
|||
note.visibility === "specified" ? note.visibleUserIds : undefined,
|
||||
renoteCount: note.renoteCount,
|
||||
repliesCount: note.repliesCount,
|
||||
reactions: convertLegacyReactions(note.reactions),
|
||||
reactions: convertReactions(note.reactions),
|
||||
reactionEmojis: reactionEmoji,
|
||||
emojis: noteEmoji,
|
||||
tags: note.tags.length > 0 ? note.tags : undefined,
|
||||
|
@ -264,7 +258,7 @@ export const NoteRepository = db.getRepository(Note).extend({
|
|||
: undefined,
|
||||
}
|
||||
: {}),
|
||||
lang: lang,
|
||||
lang: note.lang,
|
||||
});
|
||||
|
||||
if (packed.user.isCat && packed.user.speakAsCat && packed.text) {
|
||||
|
|
|
@ -53,6 +53,7 @@ import { DB_MAX_IMAGE_COMMENT_LENGTH } from "@/misc/hard-limits.js";
|
|||
import { truncate } from "@/misc/truncate.js";
|
||||
import { type Size, getEmojiSize } from "@/misc/emoji-meta.js";
|
||||
import { fetchMeta } from "@/misc/fetch-meta.js";
|
||||
import { langmap } from "@/misc/langmap.js";
|
||||
|
||||
const logger = apLogger;
|
||||
|
||||
|
@ -247,7 +248,7 @@ export async function createNote(
|
|||
// Quote
|
||||
let quote: Note | undefined | null;
|
||||
|
||||
if (note._misskey_quote || note.quoteUrl || note.quoteUri) {
|
||||
if (note.quoteUrl || note.quoteUri) {
|
||||
const tryResolveNote = async (
|
||||
uri: string,
|
||||
): Promise<
|
||||
|
@ -284,7 +285,7 @@ export async function createNote(
|
|||
};
|
||||
|
||||
const uris = unique(
|
||||
[note._misskey_quote, note.quoteUrl, note.quoteUri].filter(
|
||||
[note.quoteUrl, note.quoteUri].filter(
|
||||
(x): x is string => typeof x === "string",
|
||||
),
|
||||
);
|
||||
|
@ -305,13 +306,24 @@ export async function createNote(
|
|||
|
||||
// Text parsing
|
||||
let text: string | null = null;
|
||||
let lang: string | null = null;
|
||||
if (
|
||||
note.source?.mediaType === "text/x.misskeymarkdown" &&
|
||||
typeof note.source?.content === "string"
|
||||
) {
|
||||
text = note.source.content;
|
||||
} else if (typeof note._misskey_content !== "undefined") {
|
||||
text = note._misskey_content;
|
||||
if (note.contentMap != null) {
|
||||
const key = Object.keys(note.contentMap)[0];
|
||||
lang = Object.keys(langmap).includes(key)
|
||||
? key.trim().split("-")[0].split("@")[0]
|
||||
: null;
|
||||
}
|
||||
} else if (note.contentMap != null) {
|
||||
const entry = Object.entries(note.contentMap)[0];
|
||||
lang = Object.keys(langmap).includes(entry[0])
|
||||
? entry[0].trim().split("-")[0].split("@")[0]
|
||||
: null;
|
||||
text = htmlToMfm(entry[1], note.tag);
|
||||
} else if (typeof note.content === "string") {
|
||||
text = htmlToMfm(note.content, note.tag);
|
||||
}
|
||||
|
@ -380,6 +392,7 @@ export async function createNote(
|
|||
name: note.name,
|
||||
cw,
|
||||
text,
|
||||
lang,
|
||||
localOnly: false,
|
||||
visibility,
|
||||
visibleUsers,
|
||||
|
@ -567,13 +580,24 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
|
|||
|
||||
// Text parsing
|
||||
let text: string | null = null;
|
||||
let lang: string | null = null;
|
||||
if (
|
||||
post.source?.mediaType === "text/x.misskeymarkdown" &&
|
||||
typeof post.source?.content === "string"
|
||||
) {
|
||||
text = post.source.content;
|
||||
} else if (typeof post._misskey_content !== "undefined") {
|
||||
text = post._misskey_content;
|
||||
if (post.contentMap != null) {
|
||||
const key = Object.keys(post.contentMap)[0];
|
||||
lang = Object.keys(langmap).includes(key)
|
||||
? key.trim().split("-")[0].split("@")[0]
|
||||
: null;
|
||||
}
|
||||
} else if (post.contentMap != null) {
|
||||
const entry = Object.entries(post.contentMap)[0];
|
||||
lang = Object.keys(langmap).includes(entry[0])
|
||||
? entry[0].trim().split("-")[0].split("@")[0]
|
||||
: null;
|
||||
text = htmlToMfm(entry[1], post.tag);
|
||||
} else if (typeof post.content === "string") {
|
||||
text = htmlToMfm(post.content, post.tag);
|
||||
}
|
||||
|
@ -667,6 +691,9 @@ export async function updateNote(value: string | IObject, resolver?: Resolver) {
|
|||
if (text && text !== note.text) {
|
||||
update.text = text;
|
||||
}
|
||||
if (lang && lang !== note.lang) {
|
||||
update.lang = lang;
|
||||
}
|
||||
if (cw !== note.cw) {
|
||||
update.cw = cw ? cw : null;
|
||||
}
|
||||
|
|
|
@ -302,7 +302,10 @@ export async function createPerson(
|
|||
tags,
|
||||
isBot,
|
||||
isCat: (person as any).isCat === true,
|
||||
speakAsCat: person.speakAsCat,
|
||||
speakAsCat:
|
||||
person.speakAsCat != null
|
||||
? person.speakAsCat === true
|
||||
: (person as any).isCat === true,
|
||||
isIndexable: person.indexable,
|
||||
}),
|
||||
)) as IRemoteUser;
|
||||
|
@ -549,6 +552,10 @@ export async function updatePerson(
|
|||
tags,
|
||||
isBot: getApType(object) !== "Person",
|
||||
isCat: (person as any).isCat === true,
|
||||
speakAsCat:
|
||||
person.speakAsCat != null
|
||||
? person.speakAsCat === true
|
||||
: (person as any).isCat === true,
|
||||
isIndexable: person.indexable,
|
||||
isLocked: !!person.manuallyApprovesFollowers,
|
||||
movedToUri: person.movedTo || null,
|
||||
|
|
|
@ -35,13 +35,14 @@ export const renderActivity = (x: any): IActivity | null => {
|
|||
schema: "http://schema.org#",
|
||||
PropertyValue: "schema:PropertyValue",
|
||||
value: "schema:value",
|
||||
// Firefish
|
||||
firefish: "https://joinfirefish.org/ns#",
|
||||
speakAsCat: "firefish:speakAsCat",
|
||||
// Misskey
|
||||
misskey: "https://misskey-hub.net/ns#",
|
||||
_misskey_content: "misskey:_misskey_content",
|
||||
_misskey_quote: "misskey:_misskey_quote",
|
||||
_misskey_talk: "misskey:_misskey_talk",
|
||||
_misskey_reaction: "misskey:_misskey_reaction",
|
||||
_misskey_votes: "misskey:_misskey_votes",
|
||||
_misskey_talk: "misskey:_misskey_talk",
|
||||
isCat: "misskey:isCat",
|
||||
// Fedibird
|
||||
fedibird: "http://fedibird.com/ns#",
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { In, IsNull } from "typeorm";
|
||||
import { detect as detectLanguage } from "tinyld";
|
||||
import config from "@/config/index.js";
|
||||
import type { Note, IMentionedRemoteUsers } from "@/models/entities/note.js";
|
||||
import type { DriveFile } from "@/models/entities/drive-file.js";
|
||||
|
@ -114,6 +115,13 @@ export default async function renderNote(
|
|||
}),
|
||||
);
|
||||
|
||||
const lang = note.lang ?? detectLanguage(text);
|
||||
const contentMap = lang
|
||||
? {
|
||||
[lang]: content,
|
||||
}
|
||||
: null;
|
||||
|
||||
const emojis = await getEmojis(note.emojis);
|
||||
const apemojis = emojis.map((emoji) => renderEmoji(emoji));
|
||||
|
||||
|
@ -152,12 +160,11 @@ export default async function renderNote(
|
|||
attributedTo,
|
||||
summary,
|
||||
content,
|
||||
_misskey_content: text,
|
||||
contentMap,
|
||||
source: {
|
||||
content: text,
|
||||
mediaType: "text/x.misskeymarkdown",
|
||||
},
|
||||
_misskey_quote: quote,
|
||||
quoteUri: quote,
|
||||
quoteUrl: quote,
|
||||
published: note.createdAt.toISOString(),
|
||||
|
|
|
@ -14,6 +14,7 @@ export interface IObject {
|
|||
inReplyTo?: any;
|
||||
replies?: ICollection;
|
||||
content?: string;
|
||||
contentMap?: obj;
|
||||
name?: string;
|
||||
startTime?: Date;
|
||||
endTime?: Date;
|
||||
|
@ -134,7 +135,6 @@ export interface IPost extends IObject {
|
|||
content: string;
|
||||
mediaType: string;
|
||||
};
|
||||
_misskey_quote?: string;
|
||||
quoteUrl?: string;
|
||||
quoteUri?: string;
|
||||
_misskey_talk: boolean;
|
||||
|
@ -146,7 +146,6 @@ export interface IQuestion extends IObject {
|
|||
content: string;
|
||||
mediaType: string;
|
||||
};
|
||||
_misskey_quote?: string;
|
||||
quoteUrl?: string;
|
||||
oneOf?: IQuestionChoice[];
|
||||
anyOf?: IQuestionChoice[];
|
||||
|
|
|
@ -64,7 +64,7 @@ export const meta = {
|
|||
type: "string",
|
||||
optional: false,
|
||||
nullable: false,
|
||||
default: "/static-assets/badges/info.png",
|
||||
default: "/static-assets/badges/info.avif",
|
||||
},
|
||||
bannerUrl: {
|
||||
type: "string",
|
||||
|
@ -75,7 +75,7 @@ export const meta = {
|
|||
type: "string",
|
||||
optional: false,
|
||||
nullable: false,
|
||||
default: "/static-assets/badges/error.png",
|
||||
default: "/static-assets/badges/error.avif",
|
||||
},
|
||||
iconUrl: {
|
||||
type: "string",
|
||||
|
|
|
@ -49,7 +49,7 @@ export default define(meta, paramDef, async (ps) => {
|
|||
createNotification(user.id, "app", {
|
||||
customBody: ps.comment,
|
||||
customHeader: "Moderation Notice",
|
||||
customIcon: "/static-assets/badges/info.png",
|
||||
customIcon: "/static-assets/badges/info.avif",
|
||||
});
|
||||
|
||||
setImmediate(async () => {
|
||||
|
|
|
@ -155,7 +155,7 @@ export const meta = {
|
|||
type: "string",
|
||||
optional: false,
|
||||
nullable: false,
|
||||
default: "/static-assets/badges/info.png",
|
||||
default: "/static-assets/badges/info.avif",
|
||||
},
|
||||
bannerUrl: {
|
||||
type: "string",
|
||||
|
@ -166,7 +166,7 @@ export const meta = {
|
|||
type: "string",
|
||||
optional: false,
|
||||
nullable: false,
|
||||
default: "/static-assets/badges/error.png",
|
||||
default: "/static-assets/badges/error.avif",
|
||||
},
|
||||
iconUrl: {
|
||||
type: "string",
|
||||
|
|
|
@ -108,6 +108,7 @@ export const paramDef = {
|
|||
},
|
||||
},
|
||||
text: { type: "string", maxLength: MAX_NOTE_TEXT_LENGTH, nullable: true },
|
||||
lang: { type: "string", nullable: true, maxLength: 10 },
|
||||
cw: { type: "string", nullable: true, maxLength: 100 },
|
||||
localOnly: { type: "boolean", default: false },
|
||||
noExtractMentions: { type: "boolean", default: false },
|
||||
|
@ -294,6 +295,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
}
|
||||
: undefined,
|
||||
text: ps.text || undefined,
|
||||
lang: ps.lang,
|
||||
reply,
|
||||
renote,
|
||||
cw: ps.cw,
|
||||
|
|
|
@ -35,6 +35,8 @@ import renderUpdate from "@/remote/activitypub/renderer/update.js";
|
|||
import { deliverToRelays } from "@/services/relay.js";
|
||||
// import { deliverQuestionUpdate } from "@/services/note/polls/update.js";
|
||||
import { fetchMeta } from "@/misc/fetch-meta.js";
|
||||
import { detect as detectLanguage } from "tinyld";
|
||||
import { langmap } from "@/misc/langmap.js";
|
||||
|
||||
export const meta = {
|
||||
tags: ["notes"],
|
||||
|
@ -169,6 +171,7 @@ export const paramDef = {
|
|||
},
|
||||
},
|
||||
text: { type: "string", maxLength: MAX_NOTE_TEXT_LENGTH, nullable: true },
|
||||
lang: { type: "string", nullable: true, maxLength: 10 },
|
||||
cw: { type: "string", nullable: true, maxLength: 250 },
|
||||
localOnly: { type: "boolean", default: false },
|
||||
noExtractMentions: { type: "boolean", default: false },
|
||||
|
@ -375,6 +378,16 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
ps.text = null;
|
||||
}
|
||||
|
||||
if (ps.lang) {
|
||||
if (!Object.keys(langmap).includes(ps.lang.trim()))
|
||||
throw new Error("invalid param");
|
||||
ps.lang = ps.lang.trim().split("-")[0].split("@")[0];
|
||||
} else if (ps.text) {
|
||||
ps.lang = detectLanguage(ps.text);
|
||||
} else {
|
||||
ps.lang = null;
|
||||
}
|
||||
|
||||
let tags = [];
|
||||
let emojis = [];
|
||||
let mentionedUsers = [];
|
||||
|
@ -532,6 +545,9 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
if (ps.text !== note.text) {
|
||||
update.text = ps.text;
|
||||
}
|
||||
if (ps.lang !== note.lang) {
|
||||
update.lang = ps.lang;
|
||||
}
|
||||
if (ps.cw !== note.cw || (ps.cw && !note.cw)) {
|
||||
update.cw = ps.cw;
|
||||
}
|
||||
|
|
|
@ -492,7 +492,7 @@ router.get("/notes/:note", async (ctx, next) => {
|
|||
ctx.set("Cache-Control", "public, max-age=15");
|
||||
ctx.set(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self' 'unsafe-inline'; img-src *; frame-ancestors *",
|
||||
"default-src 'self' 'unsafe-inline' 'unsafe-eval'; connect-src *; font-src 'self' data:; img-src *; media-src *; worker-src 'self'; frame-ancestors *",
|
||||
);
|
||||
|
||||
return;
|
||||
|
|
|
@ -36,9 +36,9 @@ html
|
|||
link(rel='icon' href= icon || `/favicon.ico?${ timestamp }`)
|
||||
link(rel='apple-touch-icon' href= icon || `/apple-touch-icon.png?${ timestamp }`)
|
||||
link(rel='manifest' href='/manifest.json')
|
||||
link(rel='prefetch' href=`/static-assets/badges/info.png?${ timestamp }`)
|
||||
link(rel='prefetch' href=`/static-assets/badges/not-found.png?${ timestamp }`)
|
||||
link(rel='prefetch' href=`/static-assets/badges/error.png?${ timestamp }`)
|
||||
link(rel='prefetch' href=`/static-assets/badges/info.avif?${ timestamp }`)
|
||||
link(rel='prefetch' href=`/static-assets/badges/not-found.avif?${ timestamp }`)
|
||||
link(rel='prefetch' href=`/static-assets/badges/error.avif?${ timestamp }`)
|
||||
link(rel='stylesheet' href=`/static-assets/instance.css?${ timestamp }`)
|
||||
link(rel='modulepreload' href=`/assets/${clientEntry.file}`)
|
||||
|
||||
|
|
|
@ -67,6 +67,8 @@ import { shouldSilenceInstance } from "@/misc/should-block-instance.js";
|
|||
import meilisearch from "../../db/meilisearch.js";
|
||||
import { redisClient } from "@/db/redis.js";
|
||||
import { Mutex } from "redis-semaphore";
|
||||
import { detect as detectLanguage } from "tinyld";
|
||||
import { langmap } from "@/misc/langmap.js";
|
||||
|
||||
const mutedWordsCache = new Cache<
|
||||
{ userId: UserProfile["userId"]; mutedWords: UserProfile["mutedWords"] }[]
|
||||
|
@ -139,6 +141,7 @@ type Option = {
|
|||
createdAt?: Date | null;
|
||||
name?: string | null;
|
||||
text?: string | null;
|
||||
lang?: string | null;
|
||||
reply?: Note | null;
|
||||
renote?: Note | null;
|
||||
files?: DriveFile[] | null;
|
||||
|
@ -276,6 +279,16 @@ export default async (
|
|||
data.text = null;
|
||||
}
|
||||
|
||||
if (data.lang) {
|
||||
if (!Object.keys(langmap).includes(data.lang.trim()))
|
||||
throw new Error("invalid param");
|
||||
data.lang = data.lang.trim().split("-")[0].split("@")[0];
|
||||
} else if (data.text) {
|
||||
data.lang = detectLanguage(data.text);
|
||||
} else {
|
||||
data.lang = null;
|
||||
}
|
||||
|
||||
let tags = data.apHashtags;
|
||||
let emojis = data.apEmojis;
|
||||
let mentionedUsers = data.apMentions;
|
||||
|
@ -712,6 +725,7 @@ async function insertNote(
|
|||
: null,
|
||||
name: data.name,
|
||||
text: data.text,
|
||||
lang: data.lang,
|
||||
hasPoll: data.poll != null,
|
||||
cw: data.cw == null ? null : data.cw,
|
||||
tags: tags.map((tag) => normalizeForSearch(tag)),
|
||||
|
|
|
@ -69,6 +69,7 @@ describe("ユーザー", () => {
|
|||
avatarBlurhash: user.avatarBlurhash,
|
||||
isBot: user.isBot,
|
||||
isCat: user.isCat,
|
||||
speakAsCat: user.speakAsCat,
|
||||
instance: user.instance,
|
||||
emojis: user.emojis,
|
||||
onlineStatus: user.onlineStatus,
|
||||
|
@ -401,6 +402,7 @@ describe("ユーザー", () => {
|
|||
assert.strictEqual(response.avatarBlurhash, null);
|
||||
assert.strictEqual(response.isBot, false);
|
||||
assert.strictEqual(response.isCat, false);
|
||||
assert.strictEqual(response.speakAsCat, false);
|
||||
assert.strictEqual(response.instance, undefined);
|
||||
assert.deepStrictEqual(response.emojis, {});
|
||||
assert.strictEqual(response.onlineStatus, "unknown");
|
||||
|
@ -538,6 +540,8 @@ describe("ユーザー", () => {
|
|||
{ parameters: (): object => ({ isBot: false }) },
|
||||
{ parameters: (): object => ({ isCat: true }) },
|
||||
{ parameters: (): object => ({ isCat: false }) },
|
||||
{ parameters: (): object => ({ speakAsCat: true }) },
|
||||
{ parameters: (): object => ({ speakAsCat: false }) },
|
||||
{ parameters: (): object => ({ injectFeaturedNote: true }) },
|
||||
{ parameters: (): object => ({ injectFeaturedNote: false }) },
|
||||
{ parameters: (): object => ({ receiveAnnouncementEmail: true }) },
|
||||
|
|
|
@ -1,83 +0,0 @@
|
|||
/*
|
||||
import * as assert from 'assert';
|
||||
|
||||
import { toDbReaction } from '../src/misc/reaction-lib.js';
|
||||
|
||||
describe('toDbReaction', async () => {
|
||||
it('既存の文字列リアクションはそのまま', async () => {
|
||||
assert.strictEqual(await toDbReaction('like'), 'like');
|
||||
});
|
||||
|
||||
it('Unicodeプリンは寿司化不能とするため文字列化しない', async () => {
|
||||
assert.strictEqual(await toDbReaction('🍮'), '🍮');
|
||||
});
|
||||
|
||||
it('プリン以外の既存のリアクションは文字列化する like', async () => {
|
||||
assert.strictEqual(await toDbReaction('👍'), 'like');
|
||||
});
|
||||
|
||||
it('プリン以外の既存のリアクションは文字列化する love', async () => {
|
||||
assert.strictEqual(await toDbReaction('❤️'), 'love');
|
||||
});
|
||||
|
||||
it('プリン以外の既存のリアクションは文字列化する love 異体字セレクタなし', async () => {
|
||||
assert.strictEqual(await toDbReaction('❤'), 'love');
|
||||
});
|
||||
|
||||
it('プリン以外の既存のリアクションは文字列化する laugh', async () => {
|
||||
assert.strictEqual(await toDbReaction('😆'), 'laugh');
|
||||
});
|
||||
|
||||
it('プリン以外の既存のリアクションは文字列化する hmm', async () => {
|
||||
assert.strictEqual(await toDbReaction('🤔'), 'hmm');
|
||||
});
|
||||
|
||||
it('プリン以外の既存のリアクションは文字列化する surprise', async () => {
|
||||
assert.strictEqual(await toDbReaction('😮'), 'surprise');
|
||||
});
|
||||
|
||||
it('プリン以外の既存のリアクションは文字列化する congrats', async () => {
|
||||
assert.strictEqual(await toDbReaction('🎉'), 'congrats');
|
||||
});
|
||||
|
||||
it('プリン以外の既存のリアクションは文字列化する angry', async () => {
|
||||
assert.strictEqual(await toDbReaction('💢'), 'angry');
|
||||
});
|
||||
|
||||
it('プリン以外の既存のリアクションは文字列化する confused', async () => {
|
||||
assert.strictEqual(await toDbReaction('😥'), 'confused');
|
||||
});
|
||||
|
||||
it('プリン以外の既存のリアクションは文字列化する rip', async () => {
|
||||
assert.strictEqual(await toDbReaction('😇'), 'rip');
|
||||
});
|
||||
|
||||
it('それ以外はUnicodeのまま', async () => {
|
||||
assert.strictEqual(await toDbReaction('🍅'), '🍅');
|
||||
});
|
||||
|
||||
it('異体字セレクタ除去', async () => {
|
||||
assert.strictEqual(await toDbReaction('㊗️'), '㊗');
|
||||
});
|
||||
|
||||
it('異体字セレクタ除去 必要なし', async () => {
|
||||
assert.strictEqual(await toDbReaction('㊗'), '㊗');
|
||||
});
|
||||
|
||||
it('fallback - undefined', async () => {
|
||||
assert.strictEqual(await toDbReaction(undefined), 'like');
|
||||
});
|
||||
|
||||
it('fallback - null', async () => {
|
||||
assert.strictEqual(await toDbReaction(null), 'like');
|
||||
});
|
||||
|
||||
it('fallback - empty', async () => {
|
||||
assert.strictEqual(await toDbReaction(''), 'like');
|
||||
});
|
||||
|
||||
it('fallback - unknown', async () => {
|
||||
assert.strictEqual(await toDbReaction('unknown'), 'like');
|
||||
});
|
||||
});
|
||||
*/
|
|
@ -61,6 +61,7 @@
|
|||
"insert-text-at-cursor": "0.3.0",
|
||||
"json5": "2.2.3",
|
||||
"katex": "0.16.8",
|
||||
"libopenmpt-wasm": "github:TheEssem/libopenmpt-packaging#build",
|
||||
"matter-js": "0.19.0",
|
||||
"mfm-js": "0.23.3",
|
||||
"photoswipe": "5.3.9",
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img
|
||||
src="/static-assets/badges/not-found.png"
|
||||
src="/static-assets/badges/not-found.avif"
|
||||
class="_ghost"
|
||||
:alt="i18n.ts.notFound"
|
||||
/>
|
||||
|
|
|
@ -22,7 +22,7 @@ const props = defineProps<{
|
|||
const query = ref(props.q);
|
||||
|
||||
const search = () => {
|
||||
router.push(`/search/${query.value}`);
|
||||
router.push(`/search?q=${query.value}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
v-tooltip="capitalize(instance.softwareName)"
|
||||
class="hpaizdrt"
|
||||
:style="bg"
|
||||
@click.stop="openServerInfo"
|
||||
>
|
||||
<img class="icon" :src="getInstanceIcon(instance)" aria-hidden="true" />
|
||||
<span class="name">{{ instance.name }}</span>
|
||||
|
@ -17,8 +16,6 @@ import { ref } from "vue";
|
|||
import { instanceName } from "@/config";
|
||||
import { instance as Instance } from "@/instance";
|
||||
import { getProxiedImageUrlNullable } from "@/scripts/media-proxy";
|
||||
import { defaultStore } from "@/store";
|
||||
import { pageWindow } from "@/os";
|
||||
|
||||
const props = defineProps<{
|
||||
instance?: {
|
||||
|
@ -27,7 +24,6 @@ const props = defineProps<{
|
|||
themeColor?: string;
|
||||
softwareName?: string;
|
||||
};
|
||||
host: string | null;
|
||||
}>();
|
||||
|
||||
const ticker = ref<HTMLElement | null>(null);
|
||||
|
@ -87,13 +83,6 @@ function getInstanceIcon(instance): string {
|
|||
"/client-assets/dummy.png"
|
||||
);
|
||||
}
|
||||
|
||||
function openServerInfo() {
|
||||
if (!defaultStore.state.openServerInfo) return;
|
||||
const instanceInfoUrl =
|
||||
props.host == null ? "/about" : `/instance-info/${props.host}`;
|
||||
pageWindow(instanceInfoUrl);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -12,16 +12,28 @@
|
|||
:class="{ dmWidth: inDm }"
|
||||
>
|
||||
<div ref="gallery" @click.stop>
|
||||
<XMedia
|
||||
<template
|
||||
v-for="media in mediaList.filter((media) =>
|
||||
previewable(media),
|
||||
)"
|
||||
:key="media.id"
|
||||
:class="{ image: media.type.startsWith('image') }"
|
||||
:data-id="media.id"
|
||||
:media="media"
|
||||
:raw="raw"
|
||||
/>
|
||||
>
|
||||
<XMedia
|
||||
v-if="
|
||||
media.type.startsWith('video') ||
|
||||
media.type.startsWith('image')
|
||||
"
|
||||
:key="media.id"
|
||||
:class="{ image: media.type.startsWith('image') }"
|
||||
:data-id="media.id"
|
||||
:media="media"
|
||||
:raw="raw"
|
||||
/>
|
||||
<XModPlayer
|
||||
v-else-if="isModule(media)"
|
||||
:key="media.id"
|
||||
:module="media"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -35,8 +47,13 @@ import PhotoSwipe from "photoswipe";
|
|||
import "photoswipe/style.css";
|
||||
import XBanner from "@/components/MkMediaBanner.vue";
|
||||
import XMedia from "@/components/MkMedia.vue";
|
||||
import XModPlayer from "@/components/MkModPlayer.vue";
|
||||
import * as os from "@/os";
|
||||
import { FILE_TYPE_BROWSERSAFE } from "@/const";
|
||||
import {
|
||||
FILE_TYPE_BROWSERSAFE,
|
||||
FILE_TYPE_TRACKER_MODULES,
|
||||
FILE_EXT_TRACKER_MODULES,
|
||||
} from "@/const";
|
||||
|
||||
const props = defineProps<{
|
||||
mediaList: misskey.entities.DriveFile[];
|
||||
|
@ -170,11 +187,24 @@ onMounted(() => {
|
|||
const previewable = (file: misskey.entities.DriveFile): boolean => {
|
||||
if (file.type === "image/svg+xml") return true; // svgのwebpublic/thumbnailはpngなのでtrue
|
||||
// FILE_TYPE_BROWSERSAFEに適合しないものはブラウザで表示するのに不適切
|
||||
if (isModule(file)) return true;
|
||||
return (
|
||||
(file.type.startsWith("video") || file.type.startsWith("image")) &&
|
||||
FILE_TYPE_BROWSERSAFE.includes(file.type)
|
||||
);
|
||||
};
|
||||
|
||||
const isModule = (file: misskey.entities.DriveFile): boolean => {
|
||||
return (
|
||||
FILE_TYPE_TRACKER_MODULES.some((type) => {
|
||||
return file.type === type;
|
||||
}) ||
|
||||
FILE_EXT_TRACKER_MODULES.some((ext) => {
|
||||
return file.name.toLowerCase().endsWith("." + ext);
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const previewableCount = props.mediaList.filter((media) =>
|
||||
previewable(media),
|
||||
).length;
|
||||
|
|
516
packages/client/src/components/MkModPlayer.vue
Normal file
516
packages/client/src/components/MkModPlayer.vue
Normal file
|
@ -0,0 +1,516 @@
|
|||
<template>
|
||||
<div class="mod-player-disabled" v-if="!available">
|
||||
<MkLoading v-if="fetching" />
|
||||
<MkError v-else-if="error" @retry="load()" />
|
||||
</div>
|
||||
<div class="mod-player-disabled" v-else-if="hide" @click="toggleVisible()">
|
||||
<div>
|
||||
<b
|
||||
><i class="ph-warning ph-bold ph-lg"></i>
|
||||
{{ i18n.ts.sensitive }}</b
|
||||
>
|
||||
<span>{{ i18n.ts.clickToShow }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mod-player-enabled" v-else>
|
||||
<div class="pattern-display">
|
||||
<div class="mod-pattern" ref="modPattern" v-if="patternShow">
|
||||
<span
|
||||
v-for="(row, i) in patData[currentPattern]"
|
||||
ref="initRow"
|
||||
v-bind:class="{ modRowActive: isRowActive(i) }"
|
||||
v-if="patData.length !== 0"
|
||||
>
|
||||
<span v-bind:class="{ modColQuarter: i % 4 === 0 }">{{
|
||||
indexText(i)
|
||||
}}</span>
|
||||
<span class="mod-row-inner">{{ getRowText(row) }}</span>
|
||||
</span>
|
||||
<MkLoading v-else />
|
||||
</div>
|
||||
<div class="mod-pattern" v-else @click="showPattern()">
|
||||
<span class="modRowActive" ref="initRow">
|
||||
<span class="modColQuarter">00</span>
|
||||
<span class="mod-row-inner">|F-12Ev10XEF</span>
|
||||
</span>
|
||||
<br />
|
||||
<p>{{ i18n.ts.clickToShowPatterns }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button class="play" @click="playPause()" v-if="!loading">
|
||||
<i class="ph-pause ph-fill ph-lg" v-if="playing"></i>
|
||||
<i class="ph-play ph-fill ph-lg" v-else></i>
|
||||
</button>
|
||||
<MkLoading v-else :em="true" />
|
||||
<button class="stop" @click="stop()">
|
||||
<i class="ph-stop ph-fill ph-lg"></i>
|
||||
</button>
|
||||
<button class="loop" @click="toggleLoop()">
|
||||
<i class="ph-repeat ph-fill ph-lg" v-if="loop === -1"></i>
|
||||
<i class="ph-repeat-once ph-fill ph-lg" v-else></i>
|
||||
</button>
|
||||
<FormRange
|
||||
class="progress"
|
||||
:min="0"
|
||||
:max="length"
|
||||
v-model="position"
|
||||
:step="0.1"
|
||||
ref="progress"
|
||||
:background="false"
|
||||
:tooltips="false"
|
||||
:instant="true"
|
||||
@update:modelValue="performSeek()"
|
||||
></FormRange>
|
||||
<button class="mute" @click="toggleMute()">
|
||||
<i class="ph-speaker-simple-x ph-fill ph-lg" v-if="muted"></i>
|
||||
<i class="ph-speaker-simple-high ph-fill ph-lg" v-else></i>
|
||||
</button>
|
||||
<FormRange
|
||||
class="volume"
|
||||
:min="0"
|
||||
:max="1"
|
||||
v-model="player.context.gain.value"
|
||||
:step="0.1"
|
||||
:background="false"
|
||||
:tooltips="false"
|
||||
:instant="true"
|
||||
@update:modelValue="updateMute()"
|
||||
></FormRange>
|
||||
<a
|
||||
class="download"
|
||||
:title="i18n.ts.download"
|
||||
:href="module.url"
|
||||
target="_blank"
|
||||
>
|
||||
<i class="ph-download-simple ph-fill ph-lg"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button
|
||||
v-if="module.comment"
|
||||
v-tooltip="i18n.ts.alt"
|
||||
class="_button"
|
||||
@click.stop="captionPopup"
|
||||
>
|
||||
<i class="ph-subtitles ph-bold ph-lg"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="!hide"
|
||||
v-tooltip="i18n.ts.hide"
|
||||
class="_button"
|
||||
@click.stop="toggleVisible()"
|
||||
>
|
||||
<i class="ph-eye-slash ph-bold ph-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, shallowRef, nextTick, onDeactivated, onMounted } from "vue";
|
||||
import * as firefish from "firefish-js";
|
||||
import FormRange from "./form/range.vue";
|
||||
import { i18n } from "@/i18n";
|
||||
import * as os from "@/os";
|
||||
import { defaultStore } from "@/store";
|
||||
import { ChiptuneJsPlayer, ChiptuneJsConfig } from "@/scripts/chiptune2";
|
||||
|
||||
const props = defineProps<{
|
||||
module: firefish.entities.DriveFile;
|
||||
}>();
|
||||
|
||||
interface ModRow {
|
||||
notes: string[];
|
||||
insts: string[];
|
||||
vols: string[];
|
||||
fxs: string[];
|
||||
ops: string[];
|
||||
}
|
||||
|
||||
const available = ref(false);
|
||||
const initRow = shallowRef<HTMLSpanElement>();
|
||||
const player = shallowRef(new ChiptuneJsPlayer(new ChiptuneJsConfig()));
|
||||
let hide = ref(
|
||||
defaultStore.state.nsfw === "force"
|
||||
? true
|
||||
: props.module.isSensitive && defaultStore.state.nsfw !== "ignore",
|
||||
);
|
||||
let playing = ref(false);
|
||||
let patternShow = ref(false);
|
||||
let modPattern = ref<HTMLDivElement>();
|
||||
let progress = ref<typeof FormRange>();
|
||||
let position = ref(0);
|
||||
let patData = shallowRef([] as ModRow[][]);
|
||||
let currentPattern = ref(0);
|
||||
let nbChannels = ref(0);
|
||||
let length = ref(1);
|
||||
let muted = ref(false);
|
||||
let loop = ref(0);
|
||||
let fetching = ref(true);
|
||||
let error = ref(false);
|
||||
let loading = ref(false);
|
||||
|
||||
function load() {
|
||||
player.value
|
||||
.load(props.module.url)
|
||||
.then((result: null) => {
|
||||
buffer = result;
|
||||
available.value = true;
|
||||
error.value = false;
|
||||
fetching.value = false;
|
||||
})
|
||||
.catch((e: any) => {
|
||||
console.error(e);
|
||||
error.value = true;
|
||||
fetching.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
let currentRow = 0;
|
||||
let rowHeight = 0;
|
||||
let buffer = null;
|
||||
let isSeeking = false;
|
||||
|
||||
function captionPopup() {
|
||||
os.alert({
|
||||
type: "info",
|
||||
text: props.module.comment,
|
||||
});
|
||||
}
|
||||
|
||||
function showPattern() {
|
||||
patternShow.value = !patternShow.value;
|
||||
nextTick(() => {
|
||||
if (playing.value) display();
|
||||
else stop();
|
||||
});
|
||||
}
|
||||
|
||||
function getRowText(row: ModRow) {
|
||||
let text = "";
|
||||
for (let i = 0; i < row.notes.length; i++) {
|
||||
text = text.concat(
|
||||
"|",
|
||||
row.notes[i],
|
||||
row.insts[i],
|
||||
row.vols[i],
|
||||
row.fxs[i],
|
||||
row.ops[i],
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function playPause() {
|
||||
player.value.addHandler("onRowChange", (i: { index: number }) => {
|
||||
currentRow = i.index;
|
||||
currentPattern.value = player.value.getPattern();
|
||||
length.value = player.value.duration();
|
||||
if (!isSeeking) {
|
||||
position.value = player.value.position();
|
||||
}
|
||||
requestAnimationFrame(display);
|
||||
});
|
||||
|
||||
player.value.addHandler("onEnded", () => {
|
||||
stop();
|
||||
});
|
||||
|
||||
if (player.value.currentPlayingNode === null) {
|
||||
loading.value = true;
|
||||
player.value.play(buffer).then(() => {
|
||||
player.value.seek(position.value);
|
||||
player.value.repeat(loop.value);
|
||||
playing.value = true;
|
||||
loading.value = false;
|
||||
});
|
||||
} else {
|
||||
player.value.togglePause();
|
||||
playing.value = !player.value.currentPlayingNode.paused;
|
||||
}
|
||||
}
|
||||
|
||||
async function stop(noDisplayUpdate = false) {
|
||||
player.value.stop();
|
||||
playing.value = false;
|
||||
if (!noDisplayUpdate) {
|
||||
try {
|
||||
await player.value.play(buffer);
|
||||
display(0, true);
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
player.value.stop();
|
||||
position.value = 0;
|
||||
currentRow = 0;
|
||||
player.value.clearHandlers();
|
||||
}
|
||||
|
||||
function toggleLoop() {
|
||||
loop.value = loop.value === -1 ? 0 : -1;
|
||||
player.value.repeat(loop.value);
|
||||
}
|
||||
|
||||
let savedVolume = 0;
|
||||
|
||||
function toggleMute() {
|
||||
if (muted.value) {
|
||||
player.value.context.gain.value = savedVolume;
|
||||
savedVolume = 0;
|
||||
} else {
|
||||
savedVolume = player.value.context.gain.value;
|
||||
player.value.context.gain.value = 0;
|
||||
}
|
||||
muted.value = !muted.value;
|
||||
}
|
||||
|
||||
function updateMute() {
|
||||
muted.value = false;
|
||||
savedVolume = 0;
|
||||
}
|
||||
|
||||
function performSeek() {
|
||||
player.value.seek(position.value);
|
||||
display();
|
||||
}
|
||||
|
||||
function toggleVisible() {
|
||||
hide.value = !hide.value;
|
||||
nextTick(() => {
|
||||
stop(hide.value);
|
||||
});
|
||||
}
|
||||
|
||||
function isRowActive(i: number) {
|
||||
if (i === currentRow) {
|
||||
if (modPattern.value) {
|
||||
if (rowHeight === 0 && initRow.value)
|
||||
rowHeight = initRow.value[0].getBoundingClientRect().height;
|
||||
modPattern.value.scrollTop = currentRow * rowHeight;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
function indexText(i: number) {
|
||||
let rowText = i.toString(16);
|
||||
if (rowText.length === 1) {
|
||||
rowText = "0" + rowText;
|
||||
}
|
||||
return rowText;
|
||||
}
|
||||
|
||||
function getRow(pattern: number, rowOffset: number) {
|
||||
let notes: string[] = [],
|
||||
insts: string[] = [],
|
||||
vols: string[] = [],
|
||||
fxs: string[] = [],
|
||||
ops: string[] = [];
|
||||
|
||||
for (let channel = 0; channel < nbChannels.value; channel++) {
|
||||
const part = player.value.getPatternRowChannel(
|
||||
pattern,
|
||||
rowOffset,
|
||||
channel,
|
||||
);
|
||||
|
||||
notes.push(part.substring(0, 3));
|
||||
insts.push(part.substring(4, 6));
|
||||
vols.push(part.substring(6, 9));
|
||||
fxs.push(part.substring(10, 11));
|
||||
ops.push(part.substring(11, 13));
|
||||
}
|
||||
|
||||
return {
|
||||
notes,
|
||||
insts,
|
||||
vols,
|
||||
fxs,
|
||||
ops,
|
||||
};
|
||||
}
|
||||
|
||||
function display(_time = 0, reset = false) {
|
||||
if (!patternShow.value) return;
|
||||
|
||||
if (reset) {
|
||||
const pattern = player.value.getPattern();
|
||||
currentPattern.value = pattern;
|
||||
}
|
||||
|
||||
if (patData.value.length === 0) {
|
||||
const nbPatterns = player.value.getNumPatterns();
|
||||
const pattern = player.value.getPattern();
|
||||
|
||||
currentPattern.value = pattern;
|
||||
|
||||
if (player.value.currentPlayingNode) {
|
||||
nbChannels.value = player.value.currentPlayingNode.nbChannels;
|
||||
}
|
||||
|
||||
const patternsArray: ModRow[][] = [];
|
||||
|
||||
for (let patOffset = 0; patOffset < nbPatterns; patOffset++) {
|
||||
const rowsArray: ModRow[] = [];
|
||||
const nbRows = player.value.getPatternNumRows(patOffset);
|
||||
for (let rowOffset = 0; rowOffset < nbRows; rowOffset++) {
|
||||
rowsArray.push(getRow(patOffset, rowOffset));
|
||||
}
|
||||
patternsArray.push(rowsArray);
|
||||
}
|
||||
|
||||
patData.value = Object.freeze(patternsArray);
|
||||
}
|
||||
}
|
||||
|
||||
onDeactivated(() => {
|
||||
stop();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mod-player-enabled {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> i {
|
||||
display: block;
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
background-color: var(--fg);
|
||||
color: var(--accentLighten);
|
||||
font-size: 14px;
|
||||
opacity: 0.5;
|
||||
padding: 3px 6px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
> .buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
> * {
|
||||
background-color: var(--accentedBg);
|
||||
-webkit-backdrop-filter: var(--blur, blur(15px));
|
||||
backdrop-filter: var(--blur, blur(15px));
|
||||
color: var(--accent);
|
||||
font-size: 0.8em;
|
||||
padding: 6px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
> .pattern-display {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--fg);
|
||||
background-color: var(--panelHighlight);
|
||||
text-align: center;
|
||||
font: 12px monospace;
|
||||
white-space: pre;
|
||||
user-select: none;
|
||||
|
||||
> .mod-pattern {
|
||||
display: grid;
|
||||
overflow-y: hidden;
|
||||
height: 0;
|
||||
padding-top: calc((56.25% - 48px) / 2);
|
||||
padding-bottom: calc((56.25% - 48px) / 2);
|
||||
content-visibility: auto;
|
||||
|
||||
> .modRowActive {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
> span {
|
||||
opacity: 0.5;
|
||||
|
||||
> .modColQuarter {
|
||||
color: var(--badge);
|
||||
}
|
||||
|
||||
> .mod-row-inner {
|
||||
background: repeating-linear-gradient(
|
||||
to right,
|
||||
var(--fg) 0 4ch,
|
||||
var(--codeBoolean) 4ch 6ch,
|
||||
var(--codeNumber) 6ch 9ch,
|
||||
var(--codeString) 9ch 10ch,
|
||||
var(--error) 10ch 12ch
|
||||
);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .controls {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
background-color: var(--panelHighlight);
|
||||
|
||||
> * {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
> button,
|
||||
a {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--navFg);
|
||||
cursor: pointer;
|
||||
margin: auto;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--accentedBg);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
> .progress {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
> .volume {
|
||||
flex-shrink: 1;
|
||||
max-width: 128px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mod-player-disabled {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--infoWarnBg);
|
||||
color: var(--infoWarnFg);
|
||||
|
||||
> div {
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
|
||||
> b {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -97,7 +97,11 @@
|
|||
<div class="main">
|
||||
<div class="header-container">
|
||||
<MkAvatar class="avatar" :user="appearNote.user" />
|
||||
<XNoteHeader class="header" :note="appearNote" />
|
||||
<XNoteHeader
|
||||
class="header"
|
||||
:note="appearNote"
|
||||
:can-open-server-info="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="body">
|
||||
<MkSubNoteContent
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
v-if="showTicker"
|
||||
class="ticker"
|
||||
:instance="note.user.instance"
|
||||
:host="note.user.host"
|
||||
@click.stop="openServerInfo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -57,10 +57,12 @@ import MkInstanceTicker from "@/components/MkInstanceTicker.vue";
|
|||
import { notePage } from "@/filters/note";
|
||||
import { userPage } from "@/filters/user";
|
||||
import { i18n } from "@/i18n";
|
||||
import { pageWindow } from "@/os";
|
||||
|
||||
const props = defineProps<{
|
||||
note: misskey.entities.Note;
|
||||
pinned?: boolean;
|
||||
canOpenServerInfo?: boolean;
|
||||
}>();
|
||||
|
||||
const note = ref(props.note);
|
||||
|
@ -69,6 +71,15 @@ const showTicker =
|
|||
defaultStore.state.instanceTicker === "always" ||
|
||||
(defaultStore.state.instanceTicker === "remote" &&
|
||||
note.value.user.instance);
|
||||
|
||||
function openServerInfo() {
|
||||
if (!props.canOpenServerInfo || !defaultStore.state.openServerInfo) return;
|
||||
const instanceInfoUrl =
|
||||
note.value.user.host == null
|
||||
? "/about"
|
||||
: `/instance-info/${note.value.user.host}`;
|
||||
pageWindow(instanceInfoUrl);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img
|
||||
src="/static-assets/badges/info.png"
|
||||
src="/static-assets/badges/info.avif"
|
||||
class="_ghost"
|
||||
alt="Info"
|
||||
/>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img
|
||||
src="/static-assets/badges/info.png"
|
||||
src="/static-assets/badges/info.avif"
|
||||
class="_ghost"
|
||||
alt="Info"
|
||||
/>
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<slot name="empty">
|
||||
<div class="_fullinfo">
|
||||
<img
|
||||
src="/static-assets/badges/info.png"
|
||||
src="/static-assets/badges/info.avif"
|
||||
class="_ghost"
|
||||
alt="Error"
|
||||
/>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img
|
||||
src="/static-assets/badges/info.png"
|
||||
src="/static-assets/badges/info.avif"
|
||||
class="_ghost"
|
||||
alt="Info"
|
||||
/>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<label class="timctyfi" :class="{ disabled, easing }">
|
||||
<div class="label"><slot name="label"></slot></div>
|
||||
<div v-adaptive-border class="body">
|
||||
<div v-adaptive-border class="body" :class="{ background }">
|
||||
<div class="container">
|
||||
<input
|
||||
ref="inputEl"
|
||||
|
@ -19,7 +19,12 @@
|
|||
@touchend="tooltipHide"
|
||||
@mouseenter="tooltipShow"
|
||||
@mouseleave="tooltipHide"
|
||||
@input="(x) => (inputVal = x.target.value)"
|
||||
@input="
|
||||
(x) => {
|
||||
inputVal = x.target.value;
|
||||
if (instant) onChange(x);
|
||||
}
|
||||
"
|
||||
/>
|
||||
<datalist v-if="showTicks && steps" :id="id">
|
||||
<option
|
||||
|
@ -50,11 +55,17 @@ const props = withDefaults(
|
|||
textConverter?: (value: number) => string;
|
||||
showTicks?: boolean;
|
||||
easing?: boolean;
|
||||
background?: boolean;
|
||||
tooltips?: boolean;
|
||||
instant?: boolean;
|
||||
}>(),
|
||||
{
|
||||
step: 1,
|
||||
textConverter: (v) => v.toString(),
|
||||
easing: false,
|
||||
background: true,
|
||||
tooltips: true,
|
||||
instant: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -79,6 +90,7 @@ function onChange(x) {
|
|||
|
||||
const tooltipShowing = ref(false);
|
||||
function tooltipShow() {
|
||||
if (!props.tooltips) return;
|
||||
tooltipShowing.value = true;
|
||||
os.popup(
|
||||
defineAsyncComponent(() => import("@/components/MkTooltip.vue")),
|
||||
|
@ -94,6 +106,7 @@ function tooltipShow() {
|
|||
);
|
||||
}
|
||||
function tooltipHide() {
|
||||
if (!props.tooltips) return;
|
||||
tooltipShowing.value = false;
|
||||
}
|
||||
</script>
|
||||
|
@ -128,13 +141,21 @@ function tooltipHide() {
|
|||
$thumbWidth: 20px;
|
||||
|
||||
> .body {
|
||||
padding: 10px 12px;
|
||||
background: var(--panel);
|
||||
border: solid 1px var(--panel);
|
||||
padding: 10px 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
|
||||
&.background {
|
||||
padding: 10px 12px;
|
||||
background: var(--panel);
|
||||
border: solid 1px var(--panel);
|
||||
}
|
||||
|
||||
> .container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: $thumbHeight;
|
||||
|
||||
@mixin track {
|
||||
|
@ -155,6 +176,7 @@ function tooltipHide() {
|
|||
|
||||
&:hover {
|
||||
background: var(--accentLighten);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
> input {
|
||||
|
|
|
@ -89,7 +89,7 @@ watch(
|
|||
|
||||
<style lang="scss" scoped>
|
||||
@keyframes earwiggleleft {
|
||||
from {
|
||||
0% {
|
||||
transform: rotate(37.6deg) skew(30deg);
|
||||
}
|
||||
25% {
|
||||
|
@ -101,13 +101,13 @@ watch(
|
|||
75% {
|
||||
transform: rotate(0deg) skew(30deg);
|
||||
}
|
||||
to {
|
||||
100% {
|
||||
transform: rotate(37.6deg) skew(30deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes earwiggleright {
|
||||
from {
|
||||
0% {
|
||||
transform: rotate(-37.6deg) skew(-30deg);
|
||||
}
|
||||
30% {
|
||||
|
@ -119,7 +119,7 @@ watch(
|
|||
75% {
|
||||
transform: rotate(0deg) skew(-30deg);
|
||||
}
|
||||
to {
|
||||
100% {
|
||||
transform: rotate(-37.6deg) skew(-30deg);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<transition :name="$store.state.animation ? 'zoom' : ''" appear>
|
||||
<div class="mjndxjcg">
|
||||
<img
|
||||
src="/static-assets/badges/error.png"
|
||||
src="/static-assets/badges/error.avif"
|
||||
class="_ghost"
|
||||
alt="Error"
|
||||
/>
|
||||
|
|
|
@ -297,7 +297,7 @@ const props = withDefaults(
|
|||
}
|
||||
|
||||
@keyframes mfm-rubberBand {
|
||||
from {
|
||||
0% {
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
30% {
|
||||
|
@ -315,7 +315,7 @@ const props = withDefaults(
|
|||
75% {
|
||||
transform: scale3d(1.05, 0.95, 1);
|
||||
}
|
||||
to {
|
||||
100% {
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,6 +38,74 @@ export const FILE_TYPE_BROWSERSAFE = [
|
|||
"audio/x-flac",
|
||||
"audio/vnd.wave",
|
||||
];
|
||||
|
||||
export const FILE_TYPE_TRACKER_MODULES = [
|
||||
"audio/mod",
|
||||
"audio/x-mod",
|
||||
"audio/s3m",
|
||||
"audio/x-s3m",
|
||||
"audio/xm",
|
||||
"audio/x-xm",
|
||||
"audio/it",
|
||||
"audio/x-it",
|
||||
];
|
||||
|
||||
export const FILE_EXT_TRACKER_MODULES = [
|
||||
"mptm",
|
||||
"mod",
|
||||
"s3m",
|
||||
"xm",
|
||||
"it",
|
||||
"667",
|
||||
"669",
|
||||
"amf",
|
||||
"ams",
|
||||
"c67",
|
||||
"dbm",
|
||||
"digi",
|
||||
"dmf",
|
||||
"dsm",
|
||||
"dsym",
|
||||
"dtm",
|
||||
"far",
|
||||
"fmt",
|
||||
"imf",
|
||||
"ice",
|
||||
"j2b",
|
||||
"m15",
|
||||
"mdl",
|
||||
"med",
|
||||
"mms",
|
||||
"mt2",
|
||||
"mtm",
|
||||
"mus",
|
||||
"nst",
|
||||
"okt",
|
||||
"plm",
|
||||
"psm",
|
||||
"pt36",
|
||||
"ptm",
|
||||
"sfx",
|
||||
"sfx2",
|
||||
"st26",
|
||||
"stk",
|
||||
"stm",
|
||||
"stx",
|
||||
"stp",
|
||||
"symmod",
|
||||
"gtk",
|
||||
"gt2",
|
||||
"ult",
|
||||
"wow",
|
||||
"xmf",
|
||||
"gdm",
|
||||
"mo3",
|
||||
"oxm",
|
||||
"umx",
|
||||
"xpk",
|
||||
"ppm",
|
||||
"mmcmp",
|
||||
];
|
||||
/*
|
||||
https://github.com/sindresorhus/file-type/blob/main/supported.js
|
||||
https://github.com/sindresorhus/file-type/blob/main/core.js
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<transition :name="$store.state.animation ? 'zoom' : ''" appear>
|
||||
<div v-show="loaded" class="mjndxjch">
|
||||
<img
|
||||
src="/static-assets/badges/error.png"
|
||||
src="/static-assets/badges/error.avif"
|
||||
class="_ghost"
|
||||
alt="Error"
|
||||
/>
|
||||
|
|
|
@ -111,11 +111,15 @@
|
|||
/></FormLink>
|
||||
<FormLink to="/@panos@firefish.social"
|
||||
><Mfm
|
||||
:text="'@panos@firefish.social (Project Coordinator)'"
|
||||
:text="'@panos@firefish.social (Project coordinator)'"
|
||||
/></FormLink>
|
||||
<FormLink to="/@blackspike@mastodon.cloud"
|
||||
><Mfm
|
||||
:text="'@blackspike@mastodon.cloud (Logo Design)'"
|
||||
:text="'@blackspike@mastodon.cloud (Logo design)'"
|
||||
/></FormLink>
|
||||
<FormLink to="/@magi@minazukey.uk"
|
||||
><Mfm
|
||||
:text="'@magi@minazukey.uk (Error images)'"
|
||||
/></FormLink>
|
||||
</div>
|
||||
<h3
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img
|
||||
src="/static-assets/badges/info.png"
|
||||
src="/static-assets/badges/info.avif"
|
||||
class="_ghost"
|
||||
alt="Info"
|
||||
/>
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img
|
||||
src="/static-assets/badges/info.png"
|
||||
src="/static-assets/badges/info.avif"
|
||||
aria-label="none"
|
||||
class="_ghost"
|
||||
/>
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img
|
||||
src="/static-assets/badges/info.png"
|
||||
src="/static-assets/badges/info.avif"
|
||||
class="_ghost"
|
||||
alt="Info"
|
||||
/>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<transition :name="$store.state.animation ? 'zoom' : ''" appear>
|
||||
<div :class="$style.root">
|
||||
<img
|
||||
src="/static-assets/badges/info.png"
|
||||
src="/static-assets/badges/info.avif"
|
||||
class="_ghost"
|
||||
alt="Error"
|
||||
/>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<div class="ipledcug">
|
||||
<div class="_fullinfo">
|
||||
<img
|
||||
src="/static-assets/badges/not-found.png"
|
||||
src="/static-assets/badges/not-found.avif"
|
||||
class="_ghost"
|
||||
alt="Not found"
|
||||
/>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img
|
||||
src="/static-assets/badges/info.png"
|
||||
src="/static-assets/badges/info.avif"
|
||||
class="_ghost"
|
||||
alt="Info"
|
||||
/>
|
||||
|
|
|
@ -7,23 +7,18 @@
|
|||
<div class="shape2"></div>
|
||||
<img src="/client-assets/misskey.svg" class="misskey" />
|
||||
<div class="emojis">
|
||||
<MkEmoji :normal="true" :no-style="true" emoji="⭐" />
|
||||
<MkEmoji :normal="true" :no-style="true" emoji="❤️" />
|
||||
<MkEmoji :normal="true" :no-style="true" emoji="😆" />
|
||||
<MkEmoji :normal="true" :no-style="true" emoji="🤔" />
|
||||
<MkEmoji :normal="true" :no-style="true" emoji="😮" />
|
||||
<MkEmoji :normal="true" :no-style="true" emoji="🎉" />
|
||||
<MkEmoji :normal="true" :no-style="true" emoji="💢" />
|
||||
<MkEmoji :normal="true" :no-style="true" emoji="😥" />
|
||||
<MkEmoji :normal="true" :no-style="true" emoji="😇" />
|
||||
<MkEmoji :normal="true" :no-style="true" emoji="🥴" />
|
||||
<MkEmoji :normal="true" :no-style="true" emoji="🍮" />
|
||||
<MkEmoji
|
||||
v-for="reaction in defaultReactions"
|
||||
:normal="true"
|
||||
:no-style="true"
|
||||
:emoji="reaction"
|
||||
/>
|
||||
</div>
|
||||
<div class="main">
|
||||
<img
|
||||
:src="
|
||||
$instance.iconUrl ||
|
||||
$instance.faviconUrl ||
|
||||
instance.iconUrl ||
|
||||
instance.faviconUrl ||
|
||||
'/favicon.ico'
|
||||
"
|
||||
alt=""
|
||||
|
@ -110,7 +105,9 @@ import MkButton from "@/components/MkButton.vue";
|
|||
import MkFeaturedPhotos from "@/components/MkFeaturedPhotos.vue";
|
||||
import { instanceName } from "@/config";
|
||||
import * as os from "@/os";
|
||||
import { instance } from "@/instance";
|
||||
import { i18n } from "@/i18n";
|
||||
import { defaultReactions } from "@/store";
|
||||
|
||||
const meta = ref();
|
||||
const stats = ref();
|
||||
|
@ -183,6 +180,15 @@ function showMenu(ev) {
|
|||
os.pageWindow("/about-firefish");
|
||||
},
|
||||
},
|
||||
instance.tosUrl
|
||||
? {
|
||||
text: i18n.ts.tos,
|
||||
icon: "ph-scroll ph-bold ph-lg",
|
||||
action: () => {
|
||||
window.open(instance.tosUrl, "_blank");
|
||||
},
|
||||
}
|
||||
: null,
|
||||
],
|
||||
ev.currentTarget ?? ev.target,
|
||||
);
|
||||
|
|
372
packages/client/src/scripts/chiptune2.ts
Normal file
372
packages/client/src/scripts/chiptune2.ts
Normal file
|
@ -0,0 +1,372 @@
|
|||
import wasm from "libopenmpt-wasm";
|
||||
|
||||
const ChiptuneAudioContext = window.AudioContext;
|
||||
|
||||
export function ChiptuneJsConfig(repeatCount?: number, context?: AudioContext) {
|
||||
this.repeatCount = repeatCount;
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
ChiptuneJsConfig.prototype.constructor = ChiptuneJsConfig;
|
||||
|
||||
export function ChiptuneJsPlayer(config: object) {
|
||||
this.libopenmpt = null;
|
||||
this.config = config;
|
||||
this.audioContext = config.context || new ChiptuneAudioContext();
|
||||
this.context = this.audioContext.createGain();
|
||||
this.currentPlayingNode = null;
|
||||
this.handlers = [];
|
||||
this.touchLocked = true;
|
||||
this.volume = 1;
|
||||
}
|
||||
|
||||
ChiptuneJsPlayer.prototype.constructor = ChiptuneJsPlayer;
|
||||
|
||||
ChiptuneJsPlayer.prototype.fireEvent = function (eventName: string, response) {
|
||||
const handlers = this.handlers;
|
||||
if (handlers.length > 0) {
|
||||
for (const handler of handlers) {
|
||||
if (handler.eventName === eventName) {
|
||||
handler.handler(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.addHandler = function (
|
||||
eventName: string,
|
||||
handler: Function,
|
||||
) {
|
||||
this.handlers.push({ eventName, handler });
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.clearHandlers = function () {
|
||||
this.handlers = [];
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.onEnded = function (handler: Function) {
|
||||
this.addHandler("onEnded", handler);
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.onError = function (handler: Function) {
|
||||
this.addHandler("onError", handler);
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.duration = function () {
|
||||
return this.libopenmpt._openmpt_module_get_duration_seconds(
|
||||
this.currentPlayingNode.modulePtr,
|
||||
);
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.position = function () {
|
||||
return this.libopenmpt._openmpt_module_get_position_seconds(
|
||||
this.currentPlayingNode.modulePtr,
|
||||
);
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.repeat = function (repeatCount: number) {
|
||||
if (this.currentPlayingNode) {
|
||||
this.libopenmpt._openmpt_module_set_repeat_count(
|
||||
this.currentPlayingNode.modulePtr,
|
||||
repeatCount,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.seek = function (position: number) {
|
||||
if (this.currentPlayingNode) {
|
||||
this.libopenmpt._openmpt_module_set_position_seconds(
|
||||
this.currentPlayingNode.modulePtr,
|
||||
position,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.metadata = function () {
|
||||
const data = {};
|
||||
const keys = this.libopenmpt
|
||||
.UTF8ToString(
|
||||
this.libopenmpt._openmpt_module_get_metadata_keys(
|
||||
this.currentPlayingNode.modulePtr,
|
||||
),
|
||||
)
|
||||
.split(";");
|
||||
let keyNameBuffer = 0;
|
||||
for (const key of keys) {
|
||||
keyNameBuffer = this.libopenmpt._malloc(key.length + 1);
|
||||
this.libopenmpt.stringToUTF8(key, keyNameBuffer);
|
||||
data[key] = this.libopenmpt.UTF8ToString(
|
||||
this.libopenmpt._openmpt_module_get_metadata(
|
||||
this.currentPlayingNode.modulePtr,
|
||||
keyNameBuffer,
|
||||
),
|
||||
);
|
||||
this.libopenmpt._free(keyNameBuffer);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.unlock = function () {
|
||||
const context = this.audioContext;
|
||||
const buffer = context.createBuffer(1, 1, 22050);
|
||||
const unlockSource = context.createBufferSource();
|
||||
unlockSource.buffer = buffer;
|
||||
unlockSource.connect(this.context);
|
||||
this.context.connect(context.destination);
|
||||
unlockSource.start(0);
|
||||
this.touchLocked = false;
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.load = function (input) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.touchLocked) {
|
||||
this.unlock();
|
||||
}
|
||||
if (input instanceof File) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
resolve(reader.result);
|
||||
};
|
||||
reader.readAsArrayBuffer(input);
|
||||
} else {
|
||||
window
|
||||
.fetch(input)
|
||||
.then((response) => {
|
||||
response
|
||||
.arrayBuffer()
|
||||
.then((arrayBuffer) => {
|
||||
resolve(arrayBuffer);
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.play = async function (buffer: ArrayBuffer) {
|
||||
this.unlock();
|
||||
this.stop();
|
||||
return this.createLibopenmptNode(buffer, this.buffer).then((processNode) => {
|
||||
if (processNode === null) {
|
||||
return;
|
||||
}
|
||||
this.libopenmpt._openmpt_module_set_repeat_count(
|
||||
processNode.modulePtr,
|
||||
this.config.repeatCount || 0,
|
||||
);
|
||||
this.currentPlayingNode = processNode;
|
||||
processNode.connect(this.context);
|
||||
this.context.connect(this.audioContext.destination);
|
||||
});
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.stop = function () {
|
||||
if (this.currentPlayingNode != null) {
|
||||
this.currentPlayingNode.disconnect();
|
||||
this.currentPlayingNode.cleanup();
|
||||
this.currentPlayingNode = null;
|
||||
}
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.togglePause = function () {
|
||||
if (this.currentPlayingNode != null) {
|
||||
this.currentPlayingNode.togglePause();
|
||||
}
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.getPattern = function () {
|
||||
if (this.currentPlayingNode?.modulePtr) {
|
||||
return this.libopenmpt._openmpt_module_get_current_pattern(
|
||||
this.currentPlayingNode.modulePtr,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.getRow = function () {
|
||||
if (this.currentPlayingNode?.modulePtr) {
|
||||
return this.libopenmpt._openmpt_module_get_current_row(
|
||||
this.currentPlayingNode.modulePtr,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.getNumPatterns = function () {
|
||||
if (this.currentPlayingNode?.modulePtr) {
|
||||
return this.libopenmpt._openmpt_module_get_num_patterns(
|
||||
this.currentPlayingNode.modulePtr,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.getPatternNumRows = function (pattern: number) {
|
||||
if (this.currentPlayingNode?.modulePtr) {
|
||||
return this.libopenmpt._openmpt_module_get_pattern_num_rows(
|
||||
this.currentPlayingNode.modulePtr,
|
||||
pattern,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.getPatternRowChannel = function (
|
||||
pattern: number,
|
||||
row: number,
|
||||
channel: number,
|
||||
) {
|
||||
if (this.currentPlayingNode?.modulePtr) {
|
||||
return this.libopenmpt.UTF8ToString(
|
||||
this.libopenmpt._openmpt_module_format_pattern_row_channel(
|
||||
this.currentPlayingNode.modulePtr,
|
||||
pattern,
|
||||
row,
|
||||
channel,
|
||||
0,
|
||||
true,
|
||||
),
|
||||
);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
ChiptuneJsPlayer.prototype.createLibopenmptNode = async function (
|
||||
buffer,
|
||||
config: object,
|
||||
) {
|
||||
const maxFramesPerChunk = 4096;
|
||||
const processNode = this.audioContext.createScriptProcessor(2048, 0, 2);
|
||||
processNode.config = config;
|
||||
processNode.player = this;
|
||||
if (!this.libopenmpt) this.libopenmpt = await wasm();
|
||||
const byteArray = new Int8Array(buffer);
|
||||
const ptrToFile = this.libopenmpt._malloc(byteArray.byteLength);
|
||||
this.libopenmpt.HEAPU8.set(byteArray, ptrToFile);
|
||||
processNode.modulePtr = this.libopenmpt._openmpt_module_create_from_memory(
|
||||
ptrToFile,
|
||||
byteArray.byteLength,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
processNode.nbChannels = this.libopenmpt._openmpt_module_get_num_channels(
|
||||
processNode.modulePtr,
|
||||
);
|
||||
processNode.patternIndex = -1;
|
||||
processNode.paused = false;
|
||||
processNode.leftBufferPtr = this.libopenmpt._malloc(4 * maxFramesPerChunk);
|
||||
processNode.rightBufferPtr = this.libopenmpt._malloc(4 * maxFramesPerChunk);
|
||||
processNode.cleanup = function () {
|
||||
if (this.modulePtr !== 0) {
|
||||
processNode.player.libopenmpt._openmpt_module_destroy(this.modulePtr);
|
||||
this.modulePtr = 0;
|
||||
}
|
||||
if (this.leftBufferPtr !== 0) {
|
||||
processNode.player.libopenmpt._free(this.leftBufferPtr);
|
||||
this.leftBufferPtr = 0;
|
||||
}
|
||||
if (this.rightBufferPtr !== 0) {
|
||||
processNode.player.libopenmpt._free(this.rightBufferPtr);
|
||||
this.rightBufferPtr = 0;
|
||||
}
|
||||
};
|
||||
processNode.stop = function () {
|
||||
this.disconnect();
|
||||
this.cleanup();
|
||||
};
|
||||
processNode.pause = function () {
|
||||
this.paused = true;
|
||||
};
|
||||
processNode.unpause = function () {
|
||||
this.paused = false;
|
||||
};
|
||||
processNode.togglePause = function () {
|
||||
this.paused = !this.paused;
|
||||
};
|
||||
processNode.onaudioprocess = function (e) {
|
||||
const outputL = e.outputBuffer.getChannelData(0);
|
||||
const outputR = e.outputBuffer.getChannelData(1);
|
||||
let framesToRender = outputL.length;
|
||||
if (this.ModulePtr === 0) {
|
||||
for (let i = 0; i < framesToRender; ++i) {
|
||||
outputL[i] = 0;
|
||||
outputR[i] = 0;
|
||||
}
|
||||
this.disconnect();
|
||||
this.cleanup();
|
||||
return;
|
||||
}
|
||||
if (this.paused) {
|
||||
for (let i = 0; i < framesToRender; ++i) {
|
||||
outputL[i] = 0;
|
||||
outputR[i] = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let framesRendered = 0;
|
||||
let ended = false;
|
||||
let error = false;
|
||||
|
||||
const currentPattern =
|
||||
processNode.player.libopenmpt._openmpt_module_get_current_pattern(
|
||||
this.modulePtr,
|
||||
);
|
||||
const currentRow =
|
||||
processNode.player.libopenmpt._openmpt_module_get_current_row(
|
||||
this.modulePtr,
|
||||
);
|
||||
if (currentPattern !== this.patternIndex) {
|
||||
processNode.player.fireEvent("onPatternChange");
|
||||
}
|
||||
processNode.player.fireEvent("onRowChange", { index: currentRow });
|
||||
|
||||
while (framesToRender > 0) {
|
||||
const framesPerChunk = Math.min(framesToRender, maxFramesPerChunk);
|
||||
const actualFramesPerChunk =
|
||||
processNode.player.libopenmpt._openmpt_module_read_float_stereo(
|
||||
this.modulePtr,
|
||||
this.context.sampleRate,
|
||||
framesPerChunk,
|
||||
this.leftBufferPtr,
|
||||
this.rightBufferPtr,
|
||||
);
|
||||
if (actualFramesPerChunk === 0) {
|
||||
ended = true;
|
||||
// modulePtr will be 0 on openmpt: error: openmpt_module_read_float_stereo: ERROR: module * not valid or other openmpt error
|
||||
error = !this.modulePtr;
|
||||
}
|
||||
const rawAudioLeft = processNode.player.libopenmpt.HEAPF32.subarray(
|
||||
this.leftBufferPtr / 4,
|
||||
this.leftBufferPtr / 4 + actualFramesPerChunk,
|
||||
);
|
||||
const rawAudioRight = processNode.player.libopenmpt.HEAPF32.subarray(
|
||||
this.rightBufferPtr / 4,
|
||||
this.rightBufferPtr / 4 + actualFramesPerChunk,
|
||||
);
|
||||
for (let i = 0; i < actualFramesPerChunk; ++i) {
|
||||
outputL[framesRendered + i] = rawAudioLeft[i];
|
||||
outputR[framesRendered + i] = rawAudioRight[i];
|
||||
}
|
||||
for (let i = actualFramesPerChunk; i < framesPerChunk; ++i) {
|
||||
outputL[framesRendered + i] = 0;
|
||||
outputR[framesRendered + i] = 0;
|
||||
}
|
||||
framesToRender -= framesPerChunk;
|
||||
framesRendered += framesPerChunk;
|
||||
}
|
||||
if (ended) {
|
||||
this.disconnect();
|
||||
this.cleanup();
|
||||
error
|
||||
? processNode.player.fireEvent("onError", { type: "openmpt" })
|
||||
: processNode.player.fireEvent("onEnded");
|
||||
}
|
||||
};
|
||||
return processNode;
|
||||
};
|
|
@ -24,14 +24,16 @@ export function openHelpMenu_(ev: MouseEvent) {
|
|||
icon: "ph-lightbulb ph-bold ph-lg",
|
||||
to: "/about-firefish",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
text: i18n.ts.tos,
|
||||
icon: "ph-scroll ph-bold ph-lg",
|
||||
action: () => {
|
||||
window.open(instance.tosUrl, "_blank");
|
||||
},
|
||||
},
|
||||
instance.tosUrl
|
||||
? {
|
||||
type: "button",
|
||||
text: i18n.ts.tos,
|
||||
icon: "ph-scroll ph-bold ph-lg",
|
||||
action: () => {
|
||||
window.open(instance.tosUrl, "_blank");
|
||||
},
|
||||
}
|
||||
: null,
|
||||
{
|
||||
type: "button",
|
||||
text: i18n.ts.apps,
|
||||
|
|
|
@ -17,6 +17,21 @@ const menuOptions = [
|
|||
"search",
|
||||
];
|
||||
|
||||
export const defaultReactions = [
|
||||
"⭐",
|
||||
"❤️",
|
||||
"😆",
|
||||
"🤔",
|
||||
"😮",
|
||||
"🎉",
|
||||
"💢",
|
||||
"😥",
|
||||
"😇",
|
||||
"🥴",
|
||||
"🔥",
|
||||
"🐟",
|
||||
];
|
||||
|
||||
// TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう)
|
||||
// あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない
|
||||
export const defaultStore = markRaw(
|
||||
|
@ -83,19 +98,7 @@ export const defaultStore = markRaw(
|
|||
},
|
||||
reactions: {
|
||||
where: "account",
|
||||
default: [
|
||||
"⭐",
|
||||
"❤️",
|
||||
"😆",
|
||||
"🤔",
|
||||
"😮",
|
||||
"🎉",
|
||||
"💢",
|
||||
"😥",
|
||||
"😇",
|
||||
"🥴",
|
||||
"🍮",
|
||||
],
|
||||
default: defaultReactions,
|
||||
},
|
||||
mutedWords: {
|
||||
where: "account",
|
||||
|
|
|
@ -745,7 +745,7 @@ hr {
|
|||
}
|
||||
|
||||
@keyframes tada {
|
||||
from {
|
||||
0% {
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
|
||||
|
@ -767,14 +767,14 @@ hr {
|
|||
transform: scale3d(1.1, 1.1, 1.1) rotate3d(0, 0, 1, -3deg);
|
||||
}
|
||||
|
||||
to {
|
||||
100% {
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
@keyframes tada {
|
||||
from {
|
||||
0% {
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
|
||||
|
@ -782,7 +782,7 @@ hr {
|
|||
transform: scale3d(1.1, 1.1, 1.1);
|
||||
}
|
||||
|
||||
to {
|
||||
100% {
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
}
|
||||
|
@ -937,7 +937,7 @@ hr {
|
|||
// }
|
||||
|
||||
@keyframes reset {
|
||||
to {
|
||||
100% {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
@ -948,13 +948,13 @@ hr {
|
|||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes scaleInSmall {
|
||||
from {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
"@microsoft/api-extractor": "^7.36.0",
|
||||
"@microsoft/api-documenter": "^7.22.21",
|
||||
"@swc/cli": "^0.1.62",
|
||||
"@swc/core": "1.3.82",
|
||||
"@swc/core": "1.3.78",
|
||||
"@types/jest": "^27.4.0",
|
||||
"@types/node": "20.3.1",
|
||||
"jest": "^27.4.5",
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@swc/cli": "^0.1.62",
|
||||
"@swc/core": "1.3.82",
|
||||
"@swc/core": "1.3.78",
|
||||
"@swc/core-android-arm64": "1.3.11",
|
||||
"firefish-js": "workspace:*",
|
||||
"idb-keyval": "^6.2.1",
|
||||
|
|
3349
pnpm-lock.yaml
3349
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue