Merge branch 'develop' of https://codeberg.org/calckey/calckey into notifications

This commit is contained in:
Freeplay 2023-05-01 16:50:21 -04:00
commit f759476a4a
126 changed files with 3366 additions and 1234 deletions

View file

@ -1,7 +1,8 @@
pipeline:
testCommit:
image: node:latest
image: node:alpine
commands:
- apk add --no-cache cargo python3 make g++
- cp .config/ci.yml .config/default.yml
- corepack enable
- corepack prepare pnpm@latest --activate

View file

@ -27,9 +27,9 @@
- Notable differences:
- Improved UI/UX (especially on mobile)
- Improved notifications
- Fediverse account migration
- Improved instance security
- Improved accessibility
- Improved threads
- Recommended Instances timeline
- OCR image captioning
- New and improved Groups

File diff suppressed because it is too large Load diff

View file

@ -68,8 +68,8 @@ import: "Import"
export: "Export"
files: "Files"
download: "Download"
driveFileDeleteConfirm: "Are you sure you want to delete the file \"{name}\"? Posts\
\ with this file attached will also be deleted."
driveFileDeleteConfirm: "Are you sure you want to delete the file \"{name}\"? It\
\ will be removed from all posts that contain it as an attachment."
unfollowConfirm: "Are you sure that you want to unfollow {name}?"
exportRequested: "You've requested an export. This may take a while. It will be added\
\ to your Drive once completed."
@ -197,6 +197,7 @@ perHour: "Per Hour"
perDay: "Per Day"
stopActivityDelivery: "Stop sending activities"
blockThisInstance: "Block this instance"
silenceThisInstance: "Silence this instance"
operations: "Operations"
software: "Software"
version: "Version"
@ -218,10 +219,13 @@ clearCachedFilesConfirm: "Are you sure that you want to delete all cached remote
blockedInstances: "Blocked Instances"
blockedInstancesDescription: "List the hostnames of the instances that you want to\
\ block. Listed instances will no longer be able to communicate with this instance."
silencedInstances: "Silenced Instances"
silencedInstancesDescription: "List the hostnames of the instances that you want to\
\ silence. Accounts in the listed instances are treated as \"Silenced\", can only make follow requests, and cannot mention local accounts if not followed. This will not affect the blocked instances."
hiddenTags: "Hidden Hashtags"
hiddenTagsDescription: "List the hashtags (without the #) of the hashtags you wish\
\ to hide from trending and explore. Hidden hashtags are still discoverable via\
\ other means."
\ other means. Blocked instances are not affected even if listed here."
muteAndBlock: "Mutes and Blocks"
mutedUsers: "Muted users"
blockedUsers: "Blocked users"
@ -240,6 +244,7 @@ noCustomEmojis: "There are no emoji"
noJobs: "There are no jobs"
federating: "Federating"
blocked: "Blocked"
silenced: "Silenced"
suspended: "Suspended"
all: "All"
subscribing: "Subscribing"
@ -829,7 +834,7 @@ active: "Active"
offline: "Offline"
notRecommended: "Not recommended"
botProtection: "Bot Protection"
instanceBlocking: "Blocked Instances"
instanceBlocking: "Federation Block/Silence"
selectAccount: "Select account"
switchAccount: "Switch account"
enabled: "Enabled"
@ -1042,7 +1047,7 @@ moveFromLabel: "Account you're moving from:"
moveFromDescription: "This will set an alias of your old account so that you can move\
\ from that account to this current one. Do this BEFORE moving from your older account.\
\ Please enter the tag of the account formatted like @person@instance.com"
migrationConfirm: "Are you absolutely sure you want to migrate your acccount to {account}?\
migrationConfirm: "Are you absolutely sure you want to migrate your account to {account}?\
\ Once you do this, you won't be able to reverse it, and you won't be able to use\
\ your account normally again.\nAlso, please ensure that you've set this current\
\ account as the account you're moving from."
@ -1197,7 +1202,7 @@ _mfm:
inlineMath: "Math (Inline)"
inlineMathDescription: "Display math formulas (KaTeX) in-line"
blockMath: "Math (Block)"
blockMathDescription: "Display multi-line math formulas (KaTeX) in a block"
blockMathDescription: "Display math formulas (KaTeX) in a block"
quote: "Quote"
quoteDescription: "Displays content as a quote."
emoji: "Custom Emoji"
@ -1237,6 +1242,14 @@ _mfm:
sparkleDescription: "Gives content a sparkling particle effect."
rotate: "Rotate"
rotateDescription: "Turns content by a specified angle."
position: "Position"
positionDescription: "Move content by a specified amount."
scale: "Scale"
scaleDescription: "Scale content by a specified amount."
foreground: "Foreground color"
foregroundDescription: "Change the foreground color of text."
background: "Background color"
backgroundDescription: "Change the background color of text."
plain: "Plain"
plainDescription: "Deactivates the effects of all MFM contained within this MFM\
\ effect."

223
locales/fi.yml Normal file
View file

@ -0,0 +1,223 @@
username: Käyttäjänimi
fetchingAsApObject: Hae Fedeversestä
gotIt: Selvä!
cancel: Peruuta
enterUsername: Anna käyttäjänimi
renotedBy: Buustannut {user}
noNotes: Ei lähetyksiä
noNotifications: Ei ilmoituksia
instance: Instanssi
settings: Asetukset
basicSettings: Perusasetukset
otherSettings: Muut asetukset
openInWindow: Avaa ikkunaan
profile: Profiili
timeline: Aikajana
noAccountDescription: Käyttäjä ei ole vielä kirjoittanut kuvaustaan vielä.
login: Kirjaudu sisään
loggingIn: Kirjautuu sisään
logout: Kirjaudu ulos
uploading: Tallentaa ylös...
save: Tallenna
favorites: Kirjanmerkit
unfavorite: Poista kirjanmerkeistä
favorited: Lisätty kirjanmerkkeihin.
alreadyFavorited: Lisätty jo kirjanmerkkeihin.
cantFavorite: Ei voitu lisätä kirjanmerkkeihin.
pin: Kiinnitä profiiliin
unpin: Irroita profiilista
delete: Poista
forgotPassword: Unohtunut salasana
search: Etsi
notifications: Ilmoitukset
password: Salasana
ok: OK
noThankYou: Ei kiitos
signup: Rekisteröidy
users: Käyttäjät
addUser: Lisää käyttäjä
addInstance: Lisää instanssi
favorite: Lisää kirjanmerkkeihin
copyContent: Kopioi sisältö
deleteAndEdit: Poista ja muokkaa
copyLink: Kopioi linkki
makeFollowManuallyApprove: Seuraajapyyntö vaatii hyväksymistä
follow: Seuraa
pinned: Kiinnitä profiiliin
followRequestPending: Seuraajapyyntö odottaa
you: Sinä
unrenote: Peruuta buustaus
reaction: Reaktiot
reactionSettingDescription2: Vedä uudelleenjärjestelläksesi, napsauta poistaaksesi,
paina "+" lisätäksesi.
attachCancel: Poista liite
enterFileName: Anna tiedostonimi
mute: Hiljennä
unmute: Poista hiljennys
headlineMisskey: Avoimen lähdekoodin, hajautettu sosiaalisen median alusta, joka on
ikuisesti ilmainen! 🚀
monthAndDay: '{day}/{month}'
deleteAndEditConfirm: Oletko varma, että haluat poistaa tämän lähetyksen ja muokata
sitä? Menetät kaikki reaktiot, buustaukset ja vastaukset lähetyksestäsi.
addToList: Lisää listaan
sendMessage: Lähetä viesti
reply: Vastaa
loadMore: Lataa enemmän
showMore: Näytä enemmän
receiveFollowRequest: Seuraajapyyntö vastaanotettu
followRequestAccepted: Seuraajapyyntö hyväksytty
mentions: Maininnat
importAndExport: Tuo/Vie Tietosisältö
import: Tuo
export: Vie
files: Tiedostot
download: Lataa
unfollowConfirm: Oletko varma, ettet halua seurata enää käyttäjää {name}?
noLists: Sinulla ei ole listoja
note: Lähetys
notes: Lähetykset
following: Seuraa
createList: Luo lista
manageLists: Hallitse listoja
error: Virhe
somethingHappened: On tapahtunut virhe
retry: Yritä uudelleen
pageLoadError: Virhe ladattaessa sivua.
serverIsDead: Tämä palvelin ei vastaa. Yritä hetken kuluttua uudelleen.
youShouldUpgradeClient: Nähdäksesi tämän sivun, virkistä päivittääksesi asiakasohjelmasi.
privacy: Tietosuoja
defaultNoteVisibility: Oletusnäkyvyys
followRequest: Seuraajapyyntö
followRequests: Seuraajapyynnöt
unfollow: Poista seuraaminen
enterEmoji: Syötä emoji
renote: Buustaa
renoted: Buustattu.
cantRenote: Tätä lähetystä ei voi buustata.
cantReRenote: Buustausta ei voi buustata.
quote: Lainaus
pinnedNote: Lukittu lähetys
clickToShow: Napsauta nähdäksesi
sensitive: Herkkää sisältöä (NSFW)
add: Lisää
enableEmojiReactions: Ota käyttöön emoji-reaktiot
showEmojisInReactionNotifications: Näytä emojit reaktioilmoituksissa
reactionSetting: Reaktiot näytettäväksi reaktiovalitsimessa
rememberNoteVisibility: Muista lähetyksen näkyvyysasetukset
markAsSensitive: Merkitse herkäksi sisällöksi (NSFW)
unmarkAsSensitive: Poista merkintä herkkää sisältöä (NSFW)
renoteMute: Hiljennä buustit
renoteUnmute: Poista buustien hiljennys
block: Estä
unblock: Poista esto
unsuspend: Poista keskeytys
suspend: Keskeytys
blockConfirm: Oletko varma, että haluat estää tämän tilin?
unblockConfirm: Oletko varma, että haluat poistaa tämän tilin eston?
selectAntenna: Valitse antenni
selectWidget: Valitse vimpain
editWidgets: Muokkaa vimpaimia
editWidgetsExit: Valmis
emoji: Emoji
emojis: Emojit
emojiName: Emojin nimi
emojiUrl: Emojin URL-linkki
cacheRemoteFiles: Taltioi etätiedostot välimuistiin
flagAsBot: Merkitse tili botiksi
flagAsBotDescription: Ota tämä vaihtoehto käyttöön, jos tätä tiliä ohjaa ohjelma.
Jos se on käytössä, se toimii lippuna muille kehittäjille, jotta estetään loputtomat
vuorovaikutusketjut muiden bottien kanssa ja säädetään Calckeyn sisäiset järjestelmät
käsittelemään tätä tiliä botina.
flagAsCat: Oletko kissa? 🐱
flagAsCatDescription: Saat kissan korvat ja puhut kuin kissa!
flagSpeakAsCat: Puhu kuin kissa
flagShowTimelineReplies: Näytä vastaukset aikajanalla
addAccount: Lisää tili
loginFailed: Kirjautuminen epäonnistui
showOnRemote: Katsele etäinstanssilla
general: Yleistä
accountMoved: 'Käyttäjä on muuttanut uuteen tiliin:'
wallpaper: Taustakuva
setWallpaper: Aseta taustakuva
searchWith: 'Etsi: {q}'
youHaveNoLists: Sinulla ei ole listoja
followConfirm: Oletko varma, että haluat seurata käyttäjää {name}?
host: Isäntä
selectUser: Valitse käyttäjä
annotation: Kommentit
registeredAt: Rekisteröity
latestRequestReceivedAt: Viimeisin pyyntö vastaanotettu
latestRequestSentAt: Viimeisin pyyntö lähetetty
storageUsage: Tallennustilan käyttö
charts: Kaaviot
stopActivityDelivery: Lopeta toimintojen lähettäminen
blockThisInstance: Estä tämä instanssi
operations: Toiminnot
metadata: Metatieto
monitor: Seuranta
jobQueue: Työjono
cpuAndMemory: Prosessori ja muisti
network: Verkko
disk: Levy
clearCachedFiles: Tyhjennä välimuisti
clearCachedFilesConfirm: Oletko varma, että haluat tyhjentää kaikki välimuistiin tallennetut
etätiedostot?
blockedInstances: Estetyt instanssit
hiddenTags: Piilotetut asiatunnisteet
mention: Maininta
copyUsername: Kopioi käyttäjänimi
searchUser: Etsi käyttäjää
showLess: Sulje
youGotNewFollower: seurasi sinua
directNotes: Yksityisviestit
driveFileDeleteConfirm: Oletko varma, että haluat poistaa tiedoston " {name}"? Lähetykset,
jotka sisältyvät tiedostoon, poistuvat myös.
importRequested: Olet pyytänyt viemistä. Tämä voi viedä hetken.
exportRequested: Olet pyytänyt tuomista. Tämä voi viedä hetken. Se lisätään asemaan
kun tuonti valmistuu.
lists: Listat
followers: Seuraajat
followsYou: Seuraa sinua
pageLoadErrorDescription: Tämä yleensä johtuu verkkovirheistä tai selaimen välimuistista.
Kokeile tyhjentämällä välimuisti ja yritä sitten hetken kuluttua uudelleen.
enterListName: Anna listalle nimi
withNFiles: '{n} tiedosto(t)'
instanceInfo: Instanssin tiedot
clearQueue: Tyhjennä jono
suspendConfirm: Oletko varma, että haluat keskeyttää tämän tilin?
unsuspendConfirm: Oletko varma, että haluat poistaa tämän tilin keskeytyksen?
selectList: Valitse lista
customEmojis: Kustomoitu Emoji
addEmoji: Lisää
settingGuide: Suositellut asetukset
cacheRemoteFilesDescription: Kun tämä asetus ei ole käytössä, etätiedostot on ladattu
suoraan etäinstanssilta. Asetuksen poistaminen käytöstä vähentää tallennustilan
käyttöä, mutta lisää verkkoliikennettä kun pienoiskuvat eivät muodostu.
flagSpeakAsCatDescription: Lähetyksesi nyanifioidaan, kun olet kissatilassa
flagShowTimelineRepliesDescription: Näyttää käyttäjien vastaukset muiden käyttäjien
lähetyksiin aikajanalla, jos se on päällä.
autoAcceptFollowed: Automaattisesti hyväksy seuraamispyynnöt käyttäjiltä, joita seuraat
perHour: Tunnissa
removeWallpaper: Poista taustakuva
recipient: Vastaanottaja(t)
federation: Federaatio
software: Ohjelmisto
proxyAccount: Proxy-tili
proxyAccountDescription: Välitystili (Proxy-tili) on tili, joka toimii käyttäjien
etäseuraajana tietyin edellytyksin. Kun käyttäjä esimerkiksi lisää etäkäyttäjän
luetteloon, etäkäyttäjän toimintaa ei toimiteta instanssiin, jos yksikään paikallinen
käyttäjä ei seuraa kyseistä käyttäjää, joten välitystili seuraa sen sijaan.
latestStatus: Viimeisin tila
selectInstance: Valitse instanssi
instances: Instanssit
perDay: Päivässä
version: Versio
statistics: Tilastot
clearQueueConfirmTitle: Oletko varma, että haluat tyhjentää jonon?
introMisskey: Tervetuloa! Calckey on avoimen lähdekoodin, hajautettu sosiaalisen median
alusta, joka on ikuisesti ilmainen! 🚀
clearQueueConfirmText: Mitkään välittämättömät lähetykset, jotka ovat jonossa, eivät
federoidu. Yleensä tätä toimintoa ei tarvita.
blockedInstancesDescription: Lista instanssien isäntänimistä, jotka haluat estää.
Listatut instanssit eivät kykene kommunikoimaan enää tämän instanssin kanssa.
_lang_: Suomi

View file

@ -183,6 +183,7 @@ perHour: "1時間ごと"
perDay: "1日ごと"
stopActivityDelivery: "アクティビティの配送を停止"
blockThisInstance: "このインスタンスをブロック"
silenceThisInstance: "このインスタンスをサイレンス"
operations: "操作"
software: "ソフトウェア"
version: "バージョン"
@ -202,6 +203,8 @@ clearCachedFiles: "キャッシュをクリア"
clearCachedFilesConfirm: "キャッシュされたリモートファイルをすべて削除しますか?"
blockedInstances: "ブロックしたインスタンス"
blockedInstancesDescription: "ブロックしたいインスタンスのホストを改行で区切って設定します。ブロックされたインスタンスは、このインスタンスとやり取りできなくなります。"
silencedInstances: "サイレンスしたインスタンス"
silencedInstancesDescription: "サイレンスしたいインスタンスのホストを改行で区切って設定します。サイレンスされたインスタンスに所属するアカウントはすべて「サイレンス」として扱われ、フォローがすべてリクエストになり、フォロワーでないローカルアカウントにはメンションできなくなります。ブロックしたインスタンスには影響しません。"
muteAndBlock: "ミュートとブロック"
mutedUsers: "ミュートしたユーザー"
blockedUsers: "ブロックしたユーザー"
@ -220,6 +223,7 @@ noCustomEmojis: "絵文字はありません"
noJobs: "ジョブはありません"
federating: "連合中"
blocked: "ブロック中"
silenced: "サイレンス中"
suspended: "配信停止"
all: "全て"
subscribing: "購読中"
@ -768,7 +772,7 @@ active: "アクティブ"
offline: "オフライン"
notRecommended: "非推奨"
botProtection: "Botプロテクション"
instanceBlocking: "インスタンスブロック"
instanceBlocking: "連合ブロック・サイレンス"
selectAccount: "アカウントを選択"
switchAccount: "アカウントを切り替え"
enabled: "有効"
@ -1079,7 +1083,7 @@ _mfm:
inlineMath: "数式(インライン)"
inlineMathDescription: "数式(KaTeX)をインラインで表示します。"
blockMath: "数式(ブロック)"
blockMathDescription: "複数行の数式(KaTeX)をブロックで表示します。"
blockMathDescription: "数式(KaTeX)をブロックで表示します。"
quote: "引用"
quoteDescription: "内容が引用であることを示せます。"
emoji: "カスタム絵文字"
@ -1120,6 +1124,7 @@ _mfm:
rotateDescription: "指定した角度で回転させます。"
plain: "プレーン"
plainDescription: "内側の構文を全て無効にします。"
position: 位置
_instanceTicker:
none: "表示しない"
remote: "リモートユーザーに表示"
@ -1128,7 +1133,7 @@ _serverDisconnectedBehavior:
reload: "自動でリロード"
dialog: "ダイアログで警告"
quiet: "控えめに警告"
nothing: "何も起こらない"
nothing: "何もない"
_channel:
create: "チャンネルを作成"
edit: "チャンネルを編集"

View file

@ -986,7 +986,7 @@ _registry:
createKey: "Новый ключ"
_aboutMisskey:
about: "Calckey это форк Misskey, сделанный ThatOneCalculator, разработка которого\
\ начал с 2022."
\ началась с 2022."
contributors: "Основные соавторы"
allContributors: "Все соавторы"
source: "Исходный код"

View file

@ -1009,9 +1009,9 @@ _mfm:
blockCode: "代码(块)"
blockCodeDescription: "语法高亮显示整块程序代码。"
inlineMath: "数学公式(内嵌)"
inlineMathDescription: "显示内嵌的KaTex公式。"
inlineMathDescription: "显示内嵌的KaTeX公式。"
blockMath: "数学公式(块)"
blockMathDescription: "显示整块的多行KaTex数学公式。"
blockMathDescription: "显示整块的KaTeX数学公式。"
quote: "引用"
quoteDescription: "可以用来表示引用的内容。"
emoji: "自定义表情符号"

View file

@ -64,7 +64,7 @@ import: "匯入"
export: "匯出"
files: "檔案"
download: "下載"
driveFileDeleteConfirm: "確定要刪除檔案「{name}」嗎?使用此附件的貼文也會跟著消失。\n"
driveFileDeleteConfirm: "確定要刪除檔案「{name}」嗎?使用此附件的貼文也會跟著消失。"
unfollowConfirm: "確定要取消追隨{name}嗎?"
exportRequested: "已請求匯出。這可能會花一點時間。結束後檔案將會被放到雲端裡。"
importRequested: "已請求匯入。這可能會花一點時間"
@ -291,7 +291,7 @@ emptyDrive: "雲端硬碟為空"
emptyFolder: "資料夾為空"
unableToDelete: "無法刪除"
inputNewFileName: "輸入檔案名稱"
inputNewDescription: "請輸入新標題 "
inputNewDescription: "請輸入新標題"
inputNewFolderName: "輸入新資料夾的名稱"
circularReferenceFolder: "目標文件夾是您要移動的文件夾的子文件夾。"
hasChildFilesOrFolders: "此文件夾不是空的,無法刪除。"
@ -324,7 +324,7 @@ yearX: "{year}年"
pages: "頁面"
integration: "整合"
connectService: "己連結"
disconnectService: "己斷開 "
disconnectService: "己斷開"
enableLocalTimeline: "開啟本地時間軸"
enableGlobalTimeline: "啟用公開時間軸"
disablingTimelinesInfo: "即使您關閉了時間線功能,管理員和協調人仍可以繼續使用,以方便您。"
@ -336,7 +336,7 @@ driveCapacityPerRemoteAccount: "每個非本地用戶的雲端容量"
inMb: "以Mbps為單位"
iconUrl: "圖像URL"
bannerUrl: "橫幅圖像URL"
backgroundImageUrl: "背景圖片的來源網址 "
backgroundImageUrl: "背景圖片的來源網址"
basicInfo: "基本資訊"
pinnedUsers: "置頂用戶"
pinnedUsersDescription: "在「發現」頁面中使用換行標記想要置頂的使用者。"
@ -490,7 +490,7 @@ useObjectStorage: "使用Object Storage"
objectStorageBaseUrl: "Base URL"
objectStorageBaseUrlDesc: "引用時的URL。如果您使用的是CDN或反向代理请指定其URL例如S3“https://<bucket>.s3.amazonaws.com”GCS“https://storage.googleapis.com/<bucket>”"
objectStorageBucket: "儲存空間Bucket"
objectStorageBucketDesc: "請指定您正在使用的服務的存儲桶名稱。 "
objectStorageBucketDesc: "請指定您正在使用的服務的存儲桶名稱。"
objectStoragePrefix: "前綴"
objectStoragePrefixDesc: "它存儲在此前綴目錄下。"
objectStorageEndpoint: "端點Endpoint"
@ -560,8 +560,8 @@ disablePlayer: "關閉播放器"
expandTweet: "展開推文"
themeEditor: "主題編輯器"
description: "描述"
describeFile: "添加標題 "
enterFileDescription: "輸入標題 "
describeFile: "添加標題"
enterFileDescription: "輸入標題"
author: "作者"
leaveConfirm: "有未保存的更改。要放棄嗎?"
manage: "管理"
@ -865,7 +865,7 @@ driveCapOverrideLabel: "更改這個使用者的雲端硬碟容量上限"
driveCapOverrideCaption: "如果指定0以下的值就會被取消。"
requireAdminForView: "必須以管理者帳號登入才可以檢視。"
isSystemAccount: "由系統自動建立與管理的帳號。"
typeToConfirm: "要執行這項操作,請輸入 {x} "
typeToConfirm: "要執行這項操作,請輸入 {x}"
deleteAccount: "刪除帳號"
document: "文件"
numberOfPageCache: "快取頁面數"
@ -876,7 +876,7 @@ statusbar: "狀態列"
pleaseSelect: "請選擇"
reverse: "翻轉"
colored: "彩色"
refreshInterval: "更新間隔"
refreshInterval: "更新間隔 "
label: "標籤"
type: "類型"
speed: "速度"
@ -895,7 +895,7 @@ activeEmailValidationDescription: "積極地驗證用戶的電子郵件地址,
navbar: "導覽列"
shuffle: "隨機"
account: "帳戶"
move: "移動 "
move: "移動"
customKaTeXMacro: "自定義 KaTeX 宏"
customKaTeXMacroDescription: "使用宏來輕鬆的輸入數學表達式吧!宏的用法與 LaTeX 中的命令定義相同。你可以使用 \\newcommand{\\\
name}{content} 或 \\newcommand{\\name}[number of arguments]{content} 來輸入數學表達式。舉個例子,\\\
@ -933,11 +933,11 @@ _accountDelete:
inProgress: "正在刪除"
_ad:
back: "返回"
reduceFrequencyOfThisAd: "降低此廣告的頻率 "
reduceFrequencyOfThisAd: "降低此廣告的頻率"
_forgotPassword:
enterEmail: "請輸入您的帳戶註冊的電子郵件地址。 密碼重置連結將被發送到該電子郵件地址。"
ifNoEmail: "如果您還沒有註冊您的電子郵件地址,請聯繫管理員。 "
contactAdmin: "此實例不支持電子郵件,請聯繫您的管理員重置您的密碼。 "
ifNoEmail: "如果您還沒有註冊您的電子郵件地址,請聯繫管理員。"
contactAdmin: "此實例不支持電子郵件,請聯繫您的管理員重置您的密碼。"
_gallery:
my: "我的貼文"
liked: "喜歡的貼文"
@ -1000,7 +1000,7 @@ _mfm:
url: "URL"
urlDescription: "可以展示URL位址。"
link: "鏈接"
linkDescription: "您可以將特定範圍的文章與 URL 相關聯。 "
linkDescription: "您可以將特定範圍的文章與 URL 相關聯。"
bold: "粗體"
boldDescription: "可以將文字顯示为粗體来強調。"
small: "縮小"
@ -1012,9 +1012,9 @@ _mfm:
blockCode: "程式碼(區塊)"
blockCodeDescription: "在區塊中用高亮度顯示,例如複數行的程式碼語法。"
inlineMath: "數學公式(內嵌)"
inlineMathDescription: "顯示內嵌的KaTex數學公式。"
inlineMathDescription: "顯示內嵌的KaTeX數學公式。"
blockMath: "數學公式(方塊)"
blockMathDescription: "以區塊顯示複數行的KaTex數學式。"
blockMathDescription: "以區塊顯示KaTeX數學式。"
quote: "引用"
quoteDescription: "可以用來表示引用的内容。"
emoji: "自訂表情符號"
@ -1805,3 +1805,6 @@ migration: 遷移
homeTimeline: 主頁時間軸
swipeOnDesktop: 允許在桌面上進行手機式滑動
logoImageUrl: 圖標網址
addInstance: 增加一個實例
noInstances: 沒有實例
flagSpeakAsCat: 像貓一樣地說話

View file

@ -1,6 +1,6 @@
{
"name": "calckey",
"version": "13.2.0-dev38",
"version": "13.2.0-dev41",
"codename": "aqua",
"repository": {
"type": "git",
@ -40,6 +40,8 @@
"@bull-board/ui": "^4.10.2",
"@napi-rs/cli": "^2.15.0",
"@tensorflow/tfjs": "^3.21.0",
"focus-trap": "^7.2.0",
"focus-trap-vue": "^4.0.1",
"js-yaml": "4.1.0",
"seedrandom": "^3.0.5"
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -0,0 +1,23 @@
export class LibreTranslate1682777547198 {
name = "LibreTranslate1682777547198";
async up(queryRunner) {
await queryRunner.query(`
ALTER TABLE "meta"
ADD "libreTranslateApiUrl" character varying(512)
`);
await queryRunner.query(`
ALTER TABLE "meta"
ADD "libreTranslateApiKey" character varying(128)
`);
}
async down(queryRunner) {
await queryRunner.query(`
ALTER TABLE "meta" DROP COLUMN "libreTranslateApiKey"
`);
await queryRunner.query(`
ALTER TABLE "meta" DROP COLUMN "libreTranslateApiUrl"
`);
}
}

View file

@ -0,0 +1,165 @@
export class InstanceSilence1682891890317 {
name = "InstanceSilence1682891890317";
async up(queryRunner) {
await queryRunner.query(
`ALTER TABLE "abuse_user_report" DROP CONSTRAINT "fk_7f4e851a35d81b64dda28eee0"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_renote_muting_createdAt"`,
);
await queryRunner.query(`DROP INDEX "public"."IDX_renote_muting_muteeId"`);
await queryRunner.query(`DROP INDEX "public"."IDX_renote_muting_muterId"`);
await queryRunner.query(
`ALTER TABLE "meta" DROP COLUMN "useStarForReactionFallback"`,
);
await queryRunner.query(
`ALTER TABLE "meta" DROP COLUMN "enableGuestTimeline"`,
);
await queryRunner.query(
`ALTER TABLE "meta" ADD "silencedHosts" character varying(256) array NOT NULL DEFAULT '{}'`,
);
await queryRunner.query(
`COMMENT ON COLUMN "notification"."isRead" IS 'Whether the notification was read.'`,
);
await queryRunner.query(
`COMMENT ON COLUMN "meta"."defaultReaction" IS NULL`,
);
await queryRunner.query(
`ALTER TABLE "meta" ALTER COLUMN "secureMode" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "meta" ALTER COLUMN "privateMode" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "meta" ALTER COLUMN "allowedHosts" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "meta" ALTER COLUMN "pinnedPages" SET DEFAULT '{/featured,/channels,/explore,/pages,/about-calckey}'`,
);
await queryRunner.query(
`ALTER TABLE "meta" ALTER COLUMN "repositoryUrl" SET DEFAULT 'https://codeberg.org/calckey/calckey'`,
);
await queryRunner.query(
`ALTER TABLE "meta" ALTER COLUMN "feedbackUrl" SET DEFAULT 'https://codeberg.org/calckey/calckey/issues/new'`,
);
await queryRunner.query(
`COMMENT ON COLUMN "renote_muting"."createdAt" IS 'The created date of the Muting.'`,
);
await queryRunner.query(
`COMMENT ON COLUMN "renote_muting"."muteeId" IS 'The mutee user ID.'`,
);
await queryRunner.query(
`COMMENT ON COLUMN "renote_muting"."muterId" IS 'The muter user ID.'`,
);
await queryRunner.query(
`ALTER TABLE "page" ALTER COLUMN "isPublic" DROP DEFAULT`,
);
await queryRunner.query(
`CREATE INDEX "IDX_d1259a2c2b7bb413ff449e8711" ON "renote_muting" ("createdAt") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_7eac97594bcac5ffcf2068089b" ON "renote_muting" ("muteeId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_7aa72a5fe76019bfe8e5e0e8b7" ON "renote_muting" ("muterId") `,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_0d801c609cec4e9eb4b6b4490c" ON "renote_muting" ("muterId", "muteeId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_a9021cc2e1feb5f72d3db6e9f5" ON "abuse_user_report" ("targetUserId") `,
);
await queryRunner.query(
`ALTER TABLE "renote_muting" ADD CONSTRAINT "FK_7eac97594bcac5ffcf2068089b6" FOREIGN KEY ("muteeId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "renote_muting" ADD CONSTRAINT "FK_7aa72a5fe76019bfe8e5e0e8b7d" FOREIGN KEY ("muterId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
async down(queryRunner) {
await queryRunner.query(
`ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f"`,
);
await queryRunner.query(
`ALTER TABLE "renote_muting" DROP CONSTRAINT "FK_7aa72a5fe76019bfe8e5e0e8b7d"`,
);
await queryRunner.query(
`ALTER TABLE "renote_muting" DROP CONSTRAINT "FK_7eac97594bcac5ffcf2068089b6"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_a9021cc2e1feb5f72d3db6e9f5"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_0d801c609cec4e9eb4b6b4490c"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_7aa72a5fe76019bfe8e5e0e8b7"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_7eac97594bcac5ffcf2068089b"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_d1259a2c2b7bb413ff449e8711"`,
);
await queryRunner.query(
`ALTER TABLE "page" ALTER COLUMN "isPublic" SET DEFAULT true`,
);
await queryRunner.query(
`COMMENT ON COLUMN "renote_muting"."muterId" IS NULL`,
);
await queryRunner.query(
`COMMENT ON COLUMN "renote_muting"."muteeId" IS NULL`,
);
await queryRunner.query(
`COMMENT ON COLUMN "renote_muting"."createdAt" IS NULL`,
);
await queryRunner.query(
`ALTER TABLE "meta" ALTER COLUMN "feedbackUrl" SET DEFAULT 'https://github.com/misskey-dev/misskey/issues/new'`,
);
await queryRunner.query(
`ALTER TABLE "meta" ALTER COLUMN "repositoryUrl" SET DEFAULT 'https://github.com/misskey-dev/misskey'`,
);
await queryRunner.query(
`ALTER TABLE "meta" ALTER COLUMN "pinnedPages" SET DEFAULT '{/featured,/channels,/explore,/pages,/about-misskey}'`,
);
await queryRunner.query(
`ALTER TABLE "meta" ALTER COLUMN "allowedHosts" DROP NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "meta" ALTER COLUMN "privateMode" DROP NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "meta" ALTER COLUMN "secureMode" DROP NOT NULL`,
);
await queryRunner.query(
`COMMENT ON COLUMN "meta"."defaultReaction" IS 'The fallback reaction for emoji reacts'`,
);
await queryRunner.query(
`COMMENT ON COLUMN "notification"."isRead" IS 'Whether the Notification is read.'`,
);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "silencedHosts"`);
await queryRunner.query(
`ALTER TABLE "meta" ADD "enableGuestTimeline" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "meta" ADD "useStarForReactionFallback" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt") `,
);
await queryRunner.query(
`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "fk_7f4e851a35d81b64dda28eee0" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
}

View file

@ -26,7 +26,7 @@
"@bull-board/api": "^4.6.4",
"@bull-board/koa": "^4.6.4",
"@bull-board/ui": "^4.6.4",
"@calckey/megalodon": "5.1.24",
"@calckey/megalodon": "5.2.0",
"@discordapp/twemoji": "14.0.2",
"@elastic/elasticsearch": "7.17.0",
"@koa/cors": "3.4.3",

View file

@ -89,6 +89,11 @@ export type Source = {
authKey?: string;
isPro?: boolean;
};
libreTranslate: {
managed?: boolean;
apiUrl?: string;
apiKey?: string;
};
email: {
managed?: boolean;
address?: string;

View file

@ -18,3 +18,21 @@ export async function shouldBlockInstance(
(blockedHost) => host === blockedHost || host.endsWith(`.${blockedHost}`),
);
}
/**
* Returns whether a specific host (punycoded) should be limited.
*
* @param host punycoded instance host
* @param meta a resolved Meta table
* @returns whether the given host should be limited
*/
export async function shouldSilenceInstance(
host: Instance["host"],
meta?: Meta,
): Promise<boolean> {
const { silencedHosts } = meta ?? (await fetchMeta());
return silencedHosts.some(
(silencedHost) =>
host === silencedHost || host.endsWith(`.${silencedHost}`),
);
}

View file

@ -97,6 +97,11 @@ export class Meta {
})
public blockedHosts: string[];
@Column('varchar', {
length: 256, array: true, default: '{}',
})
public silencedHosts: string[];
@Column('boolean', {
default: false,
})
@ -386,6 +391,18 @@ export class Meta {
})
public deeplIsPro: boolean;
@Column('varchar', {
length: 512,
nullable: true,
})
public libreTranslateApiUrl: string | null;
@Column('varchar', {
length: 128,
nullable: true,
})
public libreTranslateApiKey: string | null;
@Column('varchar', {
length: 512,
nullable: true,

View file

@ -1,12 +1,13 @@
import { db } from "@/db/postgre.js";
import { Instance } from "@/models/entities/instance.js";
import type { Packed } from "@/misc/schema.js";
import { fetchMeta } from "@/misc/fetch-meta.js";
import { shouldBlockInstance } from "@/misc/should-block-instance.js";
import {
shouldBlockInstance,
shouldSilenceInstance,
} from "@/misc/should-block-instance.js";
export const InstanceRepository = db.getRepository(Instance).extend({
async pack(instance: Instance): Promise<Packed<"FederationInstance">> {
const meta = await fetchMeta();
return {
id: instance.id,
caughtAt: instance.caughtAt.toISOString(),
@ -22,6 +23,7 @@ export const InstanceRepository = db.getRepository(Instance).extend({
isNotResponding: instance.isNotResponding,
isSuspended: instance.isSuspended,
isBlocked: await shouldBlockInstance(instance.host),
isSilenced: await shouldSilenceInstance(instance.host),
softwareName: instance.softwareName,
softwareVersion: instance.softwareVersion,
openRegistrations: instance.openRegistrations,

View file

@ -68,6 +68,11 @@ export const packedFederationInstanceSchema = {
optional: false,
nullable: false,
},
isSilenced: {
type: "boolean",
optional: false,
nullable: false,
},
softwareName: {
type: "string",
optional: false,

View file

@ -10,7 +10,7 @@ import { renderPerson } from "@/remote/activitypub/renderer/person.js";
import renderEmoji from "@/remote/activitypub/renderer/emoji.js";
import { inbox as processInbox } from "@/queue/index.js";
import { isSelfHost, toPuny } from "@/misc/convert-host.js";
import { Notes, Users, Emojis, NoteReactions } from "@/models/index.js";
import { Notes, Users, Emojis, NoteReactions, FollowRequests } from "@/models/index.js";
import type { ILocalUser, User } from "@/models/entities/user.js";
import { renderLike } from "@/remote/activitypub/renderer/like.js";
import { getUserKeypair } from "@/misc/keypair-store.js";
@ -330,7 +330,7 @@ router.get("/likes/:like", async (ctx) => {
});
// follow
router.get("/follows/:follower/:followee", async (ctx) => {
router.get("/follows/:follower/:followee", async (ctx: Router.RouterContext) => {
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
@ -365,4 +365,47 @@ router.get("/follows/:follower/:followee", async (ctx) => {
setResponseType(ctx);
});
// follow request
router.get("/follows/:followRequestId", async (ctx: Router.RouterContext) => {
const verify = await checkFetch(ctx.req);
if (verify !== 200) {
ctx.status = verify;
return;
}
const followRequest = await FollowRequests.findOneBy({
id: ctx.params.followRequestId,
});
if (followRequest == null) {
ctx.status = 404;
return;
}
const [follower, followee] = await Promise.all([
Users.findOneBy({
id: followRequest.followerId,
host: IsNull(),
}),
Users.findOneBy({
id: followRequest.followeeId,
host: Not(IsNull()),
}),
]);
if (follower == null || followee == null) {
ctx.status = 404;
return;
}
const meta = await fetchMeta();
if (meta.secureMode || meta.privateMode) {
ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
} else {
ctx.set("Cache-Control", "public, max-age=180");
}
ctx.body = renderActivity(renderFollow(follower, followee));
setResponseType(ctx);
});
export default router;

View file

@ -30,6 +30,17 @@ export default define(meta, paramDef, async (ps, me) => {
set.deeplIsPro = config.deepl.isPro;
}
}
if (
config.libreTranslate.managed != null &&
config.libreTranslate.managed === true
) {
if (typeof config.libreTranslate.apiUrl === "string") {
set.libreTranslateApiUrl = config.libreTranslate.apiUrl;
}
if (typeof config.libreTranslate.apiKey === "string") {
set.libreTranslateApiKey = config.libreTranslate.apiKey;
}
}
if (config.email.managed != null && config.email.managed === true) {
set.enableEmail = true;
if (typeof config.email.address === "string") {

View file

@ -259,6 +259,16 @@ export const meta = {
nullable: false,
},
},
silencedHosts: {
type: "array",
optional: true,
nullable: false,
items: {
type: "string",
optional: false,
nullable: false,
},
},
allowedHosts: {
type: "array",
optional: true,
@ -512,7 +522,8 @@ export default define(meta, paramDef, async (ps, me) => {
enableGithubIntegration: instance.enableGithubIntegration,
enableDiscordIntegration: instance.enableDiscordIntegration,
enableServiceWorker: instance.enableServiceWorker,
translatorAvailable: instance.deeplAuthKey != null,
translatorAvailable:
instance.deeplAuthKey != null || instance.libreTranslateApiUrl != null,
pinnedPages: instance.pinnedPages,
pinnedClipId: instance.pinnedClipId,
cacheRemoteFiles: instance.cacheRemoteFiles,
@ -523,6 +534,7 @@ export default define(meta, paramDef, async (ps, me) => {
customSplashIcons: instance.customSplashIcons,
hiddenTags: instance.hiddenTags,
blockedHosts: instance.blockedHosts,
silencedHosts: instance.silencedHosts,
allowedHosts: instance.allowedHosts,
privateMode: instance.privateMode,
secureMode: instance.secureMode,
@ -564,6 +576,8 @@ export default define(meta, paramDef, async (ps, me) => {
objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle,
deeplAuthKey: instance.deeplAuthKey,
deeplIsPro: instance.deeplIsPro,
libreTranslateApiUrl: instance.libreTranslateApiUrl,
libreTranslateApiKey: instance.libreTranslateApiKey,
enableIpLogging: instance.enableIpLogging,
enableActiveEmailValidation: instance.enableActiveEmailValidation,
};

View file

@ -61,6 +61,13 @@ export const paramDef = {
type: "string",
},
},
silencedHosts: {
type: "array",
nullable: true,
items: {
type: "string",
},
},
allowedHosts: {
type: "array",
nullable: true,
@ -124,6 +131,8 @@ export const paramDef = {
summalyProxy: { type: "string", nullable: true },
deeplAuthKey: { type: "string", nullable: true },
deeplIsPro: { type: "boolean" },
libreTranslateApiUrl: { type: "string", nullable: true },
libreTranslateApiKey: { type: "string", nullable: true },
enableTwitterIntegration: { type: "boolean" },
twitterConsumerKey: { type: "string", nullable: true },
twitterConsumerSecret: { type: "string", nullable: true },
@ -217,6 +226,15 @@ export default define(meta, paramDef, async (ps, me) => {
});
}
if (Array.isArray(ps.silencedHosts)) {
let lastValue = "";
set.silencedHosts = ps.silencedHosts.sort().filter((h) => {
const lv = lastValue;
lastValue = h;
return h !== "" && h !== lv;
});
}
if (ps.themeColor !== undefined) {
set.themeColor = ps.themeColor;
}
@ -515,6 +533,22 @@ export default define(meta, paramDef, async (ps, me) => {
set.deeplIsPro = ps.deeplIsPro;
}
if (ps.libreTranslateApiUrl !== undefined) {
if (ps.libreTranslateApiUrl === "") {
set.libreTranslateApiUrl = null;
} else {
set.libreTranslateApiUrl = ps.libreTranslateApiUrl;
}
}
if (ps.libreTranslateApiKey !== undefined) {
if (ps.libreTranslateApiKey === "") {
set.libreTranslateApiKey = null;
} else {
set.libreTranslateApiKey = ps.libreTranslateApiKey;
}
}
if (ps.enableIpLogging !== undefined) {
set.enableIpLogging = ps.enableIpLogging;
}

View file

@ -34,6 +34,7 @@ export const paramDef = {
notResponding: { type: "boolean", nullable: true },
suspended: { type: "boolean", nullable: true },
federating: { type: "boolean", nullable: true },
silenced: { type: "boolean", nullable: true },
subscribing: { type: "boolean", nullable: true },
publishing: { type: "boolean", nullable: true },
limit: { type: "integer", minimum: 1, maximum: 100, default: 30 },
@ -115,6 +116,22 @@ export default define(meta, paramDef, async (ps, me) => {
}
}
if (typeof ps.silenced === "boolean") {
const meta = await fetchMeta(true);
if (ps.silenced) {
if (meta.silencedHosts.length === 0) {
return [];
}
query.andWhere("instance.host IN (:...silences)", {
silences: meta.silencedHosts,
});
} else if (meta.silencedHosts.length > 0) {
query.andWhere("instance.host NOT IN (:...silences)", {
silences: meta.silencedHosts,
});
}
}
if (typeof ps.notResponding === "boolean") {
if (ps.notResponding) {
query.andWhere("instance.isNotResponding = TRUE");

View file

@ -482,7 +482,8 @@ export default define(meta, paramDef, async (ps, me) => {
enableServiceWorker: instance.enableServiceWorker,
translatorAvailable: instance.deeplAuthKey != null,
translatorAvailable:
instance.deeplAuthKey != null || instance.libreTranslateApiUrl != null,
defaultReaction: instance.defaultReaction,
...(ps.detail

View file

@ -51,15 +51,54 @@ export default define(meta, paramDef, async (ps, user) => {
const instance = await fetchMeta();
if (instance.deeplAuthKey == null) {
if (instance.deeplAuthKey == null && instance.libreTranslateApiUrl == null) {
return 204; // TODO: 良い感じのエラー返す
}
let targetLang = ps.targetLang;
if (targetLang.includes("-")) targetLang = targetLang.split("-")[0];
if (instance.libreTranslateApiUrl != null) {
const jsonBody = {
q: note.text,
source: "auto",
target: targetLang,
format: "text",
api_key: instance.libreTranslateApiKey ?? "",
};
const url = new URL(instance.libreTranslateApiUrl);
if (url.pathname.endsWith("/")) {
url.pathname = url.pathname.slice(0, -1);
}
if (!url.pathname.endsWith("/translate")) {
url.pathname += "/translate";
}
const res = await fetch(url.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(jsonBody),
agent: getAgentByUrl,
});
const json = (await res.json()) as {
detectedLanguage?: {
confidence: number;
language: string;
};
translatedText: string;
};
return {
sourceLang: json.detectedLanguage?.language,
text: json.translatedText,
};
}
const params = new URLSearchParams();
params.append("auth_key", instance.deeplAuthKey);
params.append("auth_key", instance.deeplAuthKey ?? "");
params.append("text", note.text);
params.append("target_lang", targetLang);

View file

@ -29,6 +29,7 @@ import {
convertId,
IdConvertType as IdType,
} from "../../../native-utils/built/index.js";
import { convertAttachment } from "./mastodon/converters.js";
// re-export native rust id conversion (function and enum)
export { IdType, convertId };
@ -93,7 +94,7 @@ mastoFileRouter.post("/v1/media", upload.single("file"), async (ctx) => {
return;
}
const data = await client.uploadMedia(multipartData);
ctx.body = data.data;
ctx.body = convertAttachment(data.data as Entity.Attachment);
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -112,7 +113,7 @@ mastoFileRouter.post("/v2/media", upload.single("file"), async (ctx) => {
return;
}
const data = await client.uploadMedia(multipartData);
ctx.body = data.data;
ctx.body = convertAttachment(data.data as Entity.Attachment);
} catch (e: any) {
console.error(e);
ctx.status = 401;

View file

@ -8,6 +8,8 @@ import { apiTimelineMastodon } from "./endpoints/timeline.js";
import { apiNotificationsMastodon } from "./endpoints/notifications.js";
import { apiSearchMastodon } from "./endpoints/search.js";
import { getInstance } from "./endpoints/meta.js";
import { convertAnnouncement, convertFilter } from "./converters.js";
import { convertId, IdType } from "../index.js";
export function getClient(
BASE_URL: string,
@ -68,7 +70,7 @@ export function apiMastodonCompatible(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getInstanceAnnouncements();
ctx.body = data.data;
ctx.body = data.data.map(announcement => convertAnnouncement(announcement));
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -83,7 +85,9 @@ export function apiMastodonCompatible(router: Router): void {
const accessTokens = ctx.request.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.dismissInstanceAnnouncement(ctx.params.id);
const data = await client.dismissInstanceAnnouncement(
convertId(ctx.params.id, IdType.CalckeyId),
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
@ -100,7 +104,7 @@ export function apiMastodonCompatible(router: Router): void {
// displayed without being logged in
try {
const data = await client.getFilters();
ctx.body = data.data;
ctx.body = data.data.map(filter => convertFilter(filter));
} catch (e: any) {
console.error(e);
ctx.status = 401;

View file

@ -0,0 +1,44 @@
import { Entity } from "@calckey/megalodon";
import { convertId, IdType } from "../index.js";
function simpleConvert(data: any) {
data.id = convertId(data.id, IdType.MastodonId);
return data;
}
export function convertAccount(account: Entity.Account) { return simpleConvert(account); }
export function convertAnnouncement(announcement: Entity.Announcement) { return simpleConvert(announcement); }
export function convertAttachment(attachment: Entity.Attachment) { return simpleConvert(attachment); }
export function convertFilter(filter: Entity.Filter) { return simpleConvert(filter); }
export function convertList(list: Entity.List) { return simpleConvert(list); }
export function convertNotification(notification: Entity.Notification) {
notification.account = convertAccount(notification.account);
notification.id = convertId(notification.id, IdType.MastodonId);
if (notification.status)
notification.status = convertStatus(notification.status);
return notification;
}
export function convertPoll(poll: Entity.Poll) { return simpleConvert(poll); }
export function convertRelationship(relationship: Entity.Relationship) { return simpleConvert(relationship); }
export function convertStatus(status: Entity.Status) {
status.account = convertAccount(status.account);
status.id = convertId(status.id, IdType.MastodonId);
if (status.in_reply_to_account_id)
status.in_reply_to_account_id = convertId(status.in_reply_to_account_id, IdType.MastodonId);
if (status.in_reply_to_id)
status.in_reply_to_id = convertId(status.in_reply_to_id, IdType.MastodonId);
status.media_attachments = status.media_attachments.map(attachment => convertAttachment(attachment));
status.mentions = status.mentions.map(mention => ({
...mention,
id: convertId(mention.id, IdType.MastodonId),
}));
if (status.poll)
status.poll = convertPoll(status.poll);
if (status.reblog)
status.reblog = convertStatus(status.reblog);
return status;
}

View file

@ -3,8 +3,9 @@ import { resolveUser } from "@/remote/resolve-user.js";
import Router from "@koa/router";
import { FindOptionsWhere, IsNull } from "typeorm";
import { getClient } from "../ApiMastodonCompatibleService.js";
import { argsToBools, limitToInt } from "./timeline.js";
import { argsToBools, convertTimelinesArgsId, limitToInt } from "./timeline.js";
import { convertId, IdType } from "../../index.js";
import { convertAccount, convertList, convertRelationship, convertStatus } from "../converters.js";
const relationshipModel = {
id: "",
@ -62,9 +63,7 @@ export function apiAccountMastodon(router: Router): void {
const data = await client.updateCredentials(
(ctx.request as any).body as any,
);
let resp = data.data;
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
ctx.body = convertAccount(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -81,9 +80,7 @@ export function apiAccountMastodon(router: Router): void {
(ctx.request.query as any).acct,
"accounts",
);
let resp = data.data.accounts[0];
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
ctx.body = convertAccount(data.data.accounts[0]);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -91,255 +88,6 @@ export function apiAccountMastodon(router: Router): void {
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>("/v1/accounts/:id", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const calcId = convertId(ctx.params.id, IdType.CalckeyId);
const data = await client.getAccount(calcId);
let resp = data.data;
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/statuses",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountStatuses(
convertId(ctx.params.id, IdType.CalckeyId),
argsToBools(limitToInt(ctx.query as any)),
);
let resp = data.data;
for (let statIdx = 0; statIdx < resp.length; statIdx++) {
resp[statIdx].id = convertId(resp[statIdx].id, IdType.MastodonId);
resp[statIdx].in_reply_to_account_id = resp[statIdx]
.in_reply_to_account_id
? convertId(resp[statIdx].in_reply_to_account_id, IdType.MastodonId)
: null;
resp[statIdx].in_reply_to_id = resp[statIdx].in_reply_to_id
? convertId(resp[statIdx].in_reply_to_id, IdType.MastodonId)
: null;
let mentions = resp[statIdx].mentions;
for (let mtnIdx = 0; mtnIdx < mentions.length; mtnIdx++) {
resp[statIdx].mentions[mtnIdx].id = convertId(
mentions[mtnIdx].id,
IdType.MastodonId,
);
}
}
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/followers",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountFollowers(
convertId(ctx.params.id, IdType.CalckeyId),
limitToInt(ctx.query as any),
);
let resp = data.data;
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId);
}
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/following",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountFollowing(
convertId(ctx.params.id, IdType.CalckeyId),
limitToInt(ctx.query as any),
);
let resp = data.data;
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId);
}
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/lists",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountLists(ctx.params.id);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/follow",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.followAccount(
convertId(ctx.params.id, IdType.CalckeyId),
);
let acct = data.data;
acct.following = true;
acct.id = convertId(acct.id, IdType.MastodonId);
ctx.body = acct;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/unfollow",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unfollowAccount(
convertId(ctx.params.id, IdType.CalckeyId),
);
let acct = data.data;
acct.id = convertId(acct.id, IdType.MastodonId);
acct.following = false;
ctx.body = acct;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/block",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.blockAccount(
convertId(ctx.params.id, IdType.CalckeyId),
);
let resp = data.data;
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/unblock",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unblockAccount(
convertId(ctx.params.id, IdType.MastodonId),
);
let resp = data.data;
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/mute",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.muteAccount(
convertId(ctx.params.id, IdType.CalckeyId),
(ctx.request as any).body as any,
);
let resp = data.data;
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/unmute",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unmuteAccount(
convertId(ctx.params.id, IdType.CalckeyId),
);
let resp = data.data;
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get("/v1/accounts/relationships", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
@ -364,11 +112,7 @@ export function apiAccountMastodon(router: Router): void {
}
const data = await client.getRelationships(reqIds);
let resp = data.data;
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId);
}
ctx.body = resp;
ctx.body = data.data.map(relationship => convertRelationship(relationship));
} catch (e: any) {
console.error(e);
let data = e.response.data;
@ -378,33 +122,228 @@ export function apiAccountMastodon(router: Router): void {
ctx.body = data;
}
});
router.get<{ Params: { id: string } }>("/v1/accounts/:id", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const calcId = convertId(ctx.params.id, IdType.CalckeyId);
const data = await client.getAccount(calcId);
ctx.body = convertAccount(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
});
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/statuses",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountStatuses(
convertId(ctx.params.id, IdType.CalckeyId),
convertTimelinesArgsId(argsToBools(limitToInt(ctx.query as any))),
);
ctx.body = data.data.map(status => convertStatus(status));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/followers",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountFollowers(
convertId(ctx.params.id, IdType.CalckeyId),
convertTimelinesArgsId(limitToInt(ctx.query as any)),
);
ctx.body = data.data.map(account => convertAccount(account));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/following",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountFollowing(
convertId(ctx.params.id, IdType.CalckeyId),
convertTimelinesArgsId(limitToInt(ctx.query as any)),
);
ctx.body = data.data.map(account => convertAccount(account));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get<{ Params: { id: string } }>(
"/v1/accounts/:id/lists",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountLists(
convertId(ctx.params.id, IdType.CalckeyId)
);
ctx.body = data.data.map(list => convertList(list));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/follow",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.followAccount(
convertId(ctx.params.id, IdType.CalckeyId),
);
let acct = convertRelationship(data.data);
acct.following = true;
ctx.body = acct;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/unfollow",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unfollowAccount(
convertId(ctx.params.id, IdType.CalckeyId),
);
let acct = convertRelationship(data.data);
acct.following = false;
ctx.body = acct;
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/block",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.blockAccount(
convertId(ctx.params.id, IdType.CalckeyId),
);
ctx.body = convertRelationship(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/unblock",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unblockAccount(
convertId(ctx.params.id, IdType.MastodonId),
);
ctx.body = convertRelationship(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/mute",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.muteAccount(
convertId(ctx.params.id, IdType.CalckeyId),
(ctx.request as any).body as any,
);
ctx.body = convertRelationship(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.post<{ Params: { id: string } }>(
"/v1/accounts/:id/unmute",
async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unmuteAccount(
convertId(ctx.params.id, IdType.CalckeyId),
);
ctx.body = convertRelationship(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
ctx.status = 401;
ctx.body = e.response.data;
}
},
);
router.get("/v1/bookmarks", async (ctx) => {
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = (await client.getBookmarks(
limitToInt(ctx.query as any),
)) as any;
let resp = data.data;
for (let statIdx = 0; statIdx < resp.length; statIdx++) {
resp[statIdx].id = convertId(resp[statIdx].id, IdType.MastodonId);
resp[statIdx].in_reply_to_account_id = resp[statIdx]
.in_reply_to_account_id
? convertId(resp[statIdx].in_reply_to_account_id, IdType.MastodonId)
: null;
resp[statIdx].in_reply_to_id = resp[statIdx].in_reply_to_id
? convertId(resp[statIdx].in_reply_to_id, IdType.MastodonId)
: null;
let mentions = resp[statIdx].mentions;
for (let mtnIdx = 0; mtnIdx < mentions.length; mtnIdx++) {
resp[statIdx].mentions[mtnIdx].id = convertId(
mentions[mtnIdx].id,
IdType.MastodonId,
);
}
}
ctx.body = resp;
convertTimelinesArgsId(limitToInt(ctx.query as any)),
));
ctx.body = data.data.map(status => convertStatus(status));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -417,26 +356,8 @@ export function apiAccountMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getFavourites(limitToInt(ctx.query as any));
let resp = data.data;
for (let statIdx = 0; statIdx < resp.length; statIdx++) {
resp[statIdx].id = convertId(resp[statIdx].id, IdType.MastodonId);
resp[statIdx].in_reply_to_account_id = resp[statIdx]
.in_reply_to_account_id
? convertId(resp[statIdx].in_reply_to_account_id, IdType.MastodonId)
: null;
resp[statIdx].in_reply_to_id = resp[statIdx].in_reply_to_id
? convertId(resp[statIdx].in_reply_to_id, IdType.MastodonId)
: null;
let mentions = resp[statIdx].mentions;
for (let mtnIdx = 0; mtnIdx < mentions.length; mtnIdx++) {
resp[statIdx].mentions[mtnIdx].id = convertId(
mentions[mtnIdx].id,
IdType.MastodonId,
);
}
}
ctx.body = resp;
const data = await client.getFavourites(convertTimelinesArgsId(limitToInt(ctx.query as any)));
ctx.body = data.data.map(status => convertStatus(status));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -449,12 +370,8 @@ export function apiAccountMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getMutes(limitToInt(ctx.query as any));
let resp = data.data;
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId);
}
ctx.body = resp;
const data = await client.getMutes(convertTimelinesArgsId(limitToInt(ctx.query as any)));
ctx.body = data.data.map(account => convertAccount(account));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -467,12 +384,8 @@ export function apiAccountMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getBlocks(limitToInt(ctx.query as any));
let resp = data.data;
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId);
}
ctx.body = resp;
const data = await client.getBlocks(convertTimelinesArgsId(limitToInt(ctx.query as any)));
ctx.body = data.data.map(account => convertAccount(account));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -488,11 +401,7 @@ export function apiAccountMastodon(router: Router): void {
const data = await client.getFollowRequests(
((ctx.query as any) || { limit: 20 }).limit,
);
let resp = data.data;
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId);
}
ctx.body = resp;
ctx.body = data.data.map(account => convertAccount(account));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -510,9 +419,7 @@ export function apiAccountMastodon(router: Router): void {
const data = await client.acceptFollowRequest(
convertId(ctx.params.id, IdType.CalckeyId),
);
let resp = data.data;
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
ctx.body = convertRelationship(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -531,9 +438,7 @@ export function apiAccountMastodon(router: Router): void {
const data = await client.rejectFollowRequest(
convertId(ctx.params.id, IdType.CalckeyId),
);
let resp = data.data;
resp.id = convertId(resp.id, IdType.MastodonId);
ctx.body = resp;
ctx.body = convertRelationship(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);

View file

@ -1,6 +1,8 @@
import megalodon, { MegalodonInterface } from "@calckey/megalodon";
import Router from "@koa/router";
import { getClient } from "../ApiMastodonCompatibleService.js";
import { IdType, convertId } from "../../index.js";
import { convertFilter } from "../converters.js";
export function apiFilterMastodon(router: Router): void {
router.get("/v1/filters", async (ctx) => {
@ -10,7 +12,7 @@ export function apiFilterMastodon(router: Router): void {
const body: any = ctx.request.body;
try {
const data = await client.getFilters();
ctx.body = data.data;
ctx.body = data.data.map(filter => convertFilter(filter));
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -24,8 +26,10 @@ export function apiFilterMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.getFilter(ctx.params.id);
ctx.body = data.data;
const data = await client.getFilter(
convertId(ctx.params.id, IdType.CalckeyId)
);
ctx.body = convertFilter(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -40,7 +44,7 @@ export function apiFilterMastodon(router: Router): void {
const body: any = ctx.request.body;
try {
const data = await client.createFilter(body.phrase, body.context, body);
ctx.body = data.data;
ctx.body = convertFilter(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -55,11 +59,11 @@ export function apiFilterMastodon(router: Router): void {
const body: any = ctx.request.body;
try {
const data = await client.updateFilter(
ctx.params.id,
convertId(ctx.params.id, IdType.CalckeyId),
body.phrase,
body.context,
);
ctx.body = data.data;
ctx.body = convertFilter(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -73,7 +77,9 @@ export function apiFilterMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.deleteFilter(ctx.params.id);
const data = await client.deleteFilter(
convertId(ctx.params.id, IdType.CalckeyId)
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);

View file

@ -1,8 +1,10 @@
import megalodon, { MegalodonInterface } from "@calckey/megalodon";
import Router from "@koa/router";
import { koaBody } from "koa-body";
import { convertId, IdType } from "../../index.js";
import { getClient } from "../ApiMastodonCompatibleService.js";
import { toTextWithReaction } from "./timeline.js";
import { convertTimelinesArgsId, toTextWithReaction } from "./timeline.js";
import { convertNotification } from "../converters.js";
function toLimitToInt(q: any) {
if (q.limit) if (typeof q.limit === "string") q.limit = parseInt(q.limit, 10);
return q;
@ -15,9 +17,10 @@ export function apiNotificationsMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.getNotifications(toLimitToInt(ctx.query));
const data = await client.getNotifications(convertTimelinesArgsId(toLimitToInt(ctx.query)));
const notfs = data.data;
const ret = notfs.map((n) => {
n = convertNotification(n);
if (n.type !== "follow" && n.type !== "follow_request") {
if (n.type === "reaction") n.type = "favourite";
n.status = toTextWithReaction(
@ -43,8 +46,10 @@ export function apiNotificationsMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const dataRaw = await client.getNotification(ctx.params.id);
const data = dataRaw.data;
const dataRaw = await client.getNotification(
convertId(ctx.params.id, IdType.CalckeyId)
);
const data = convertNotification(dataRaw.data);
if (data.type !== "follow" && data.type !== "follow_request") {
if (data.type === "reaction") data.type = "favourite";
ctx.body = toTextWithReaction([data as any], ctx.request.hostname)[0];
@ -79,7 +84,9 @@ export function apiNotificationsMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const data = await client.dismissNotification(ctx.params.id);
const data = await client.dismissNotification(
convertId(ctx.params.id, IdType.CalckeyId)
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);

View file

@ -3,7 +3,8 @@ import Router from "@koa/router";
import { getClient } from "../ApiMastodonCompatibleService.js";
import axios from "axios";
import { Converter } from "@calckey/megalodon";
import { limitToInt } from "./timeline.js";
import { convertTimelinesArgsId, limitToInt } from "./timeline.js";
import { convertAccount, convertStatus } from "../converters.js";
export function apiSearchMastodon(router: Router): void {
router.get("/v1/search", async (ctx) => {
@ -12,7 +13,7 @@ export function apiSearchMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
const body: any = ctx.request.body;
try {
const query: any = limitToInt(ctx.query);
const query: any = convertTimelinesArgsId(limitToInt(ctx.query));
const type = query.type || "";
const data = await client.search(query.q, type, query);
ctx.body = data.data;
@ -27,18 +28,18 @@ export function apiSearchMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const query: any = limitToInt(ctx.query);
const query: any = convertTimelinesArgsId(limitToInt(ctx.query));
const type = query.type;
if (type) {
const data = await client.search(query.q, type, query);
ctx.body = data.data;
ctx.body = data.data.accounts.map(account => convertAccount(account));
} else {
const acct = await client.search(query.q, "accounts", query);
const stat = await client.search(query.q, "statuses", query);
const tags = await client.search(query.q, "hashtags", query);
ctx.body = {
accounts: acct.data.accounts,
statuses: stat.data.statuses,
accounts: acct.data.accounts.map(account => convertAccount(account)),
statuses: stat.data.statuses.map(status => convertStatus(status)),
hashtags: tags.data.hashtags,
};
}
@ -57,7 +58,7 @@ export function apiSearchMastodon(router: Router): void {
ctx.request.hostname,
accessTokens,
);
ctx.body = data;
ctx.body = data.map(status => convertStatus(status));
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -69,12 +70,16 @@ export function apiSearchMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
try {
const query: any = ctx.query;
const data = await getFeaturedUser(
let data = await getFeaturedUser(
BASE_URL,
ctx.request.hostname,
accessTokens,
query.limit || 20,
);
data = data.map(suggestion => {
suggestion.account = convertAccount(suggestion.account);
return suggestion;
});
console.log(data);
ctx.body = data;
} catch (e: any) {

View file

@ -4,7 +4,9 @@ import { emojiRegexAtStartToEnd } from "@/misc/emoji-regex.js";
import axios from "axios";
import querystring from "node:querystring";
import qs from "qs";
import { limitToInt } from "./timeline.js";
import { convertTimelinesArgsId, limitToInt } from "./timeline.js";
import { convertId, IdType } from "../../index.js";
import { convertAccount, convertAttachment, convertPoll, convertStatus } from "../converters.js";
function normalizeQuery(data: any) {
const str = querystring.stringify(data);
@ -18,6 +20,8 @@ export function apiStatusMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
try {
let body: any = ctx.request.body;
if (body.in_reply_to_id)
body.in_reply_to_id = convertId(body.in_reply_to_id, IdType.CalckeyId);
if (
(!body.poll && body["poll[options][]"]) ||
(!body.media_ids && body["media_ids[]"])
@ -54,7 +58,7 @@ export function apiStatusMastodon(router: Router): void {
body.sensitive =
typeof sensitive === "string" ? sensitive === "true" : sensitive;
const data = await client.postStatus(text, body);
ctx.body = data.data;
ctx.body = convertStatus(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -66,8 +70,10 @@ export function apiStatusMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getStatus(ctx.params.id);
ctx.body = data.data;
const data = await client.getStatus(
convertId(ctx.params.id, IdType.CalckeyId),
);
ctx.body = convertStatus(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -79,7 +85,9 @@ export function apiStatusMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteStatus(ctx.params.id);
const data = await client.deleteStatus(
convertId(ctx.params.id, IdType.CalckeyId)
);
ctx.body = data.data;
} catch (e: any) {
console.error(e.response.data, request.params.id);
@ -100,10 +108,10 @@ export function apiStatusMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const id = ctx.params.id;
const id = convertId(ctx.params.id, IdType.CalckeyId);
const data = await client.getStatusContext(
id,
limitToInt(ctx.query as any),
convertTimelinesArgsId(limitToInt(ctx.query as any)),
);
const status = await client.getStatus(id);
let reqInstance = axios.create({
@ -126,6 +134,8 @@ export function apiStatusMastodon(router: Router): void {
text,
),
);
data.data.ancestors = data.data.ancestors.map(status => convertStatus(status));
data.data.descendants = data.data.descendants.map(status => convertStatus(status));
ctx.body = data.data;
} catch (e: any) {
console.error(e);
@ -141,8 +151,10 @@ export function apiStatusMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getStatusRebloggedBy(ctx.params.id);
ctx.body = data.data;
const data = await client.getStatusRebloggedBy(
convertId(ctx.params.id, IdType.CalckeyId)
);
ctx.body = data.data.map(account => convertAccount(account));
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -165,11 +177,11 @@ export function apiStatusMastodon(router: Router): void {
const react = await getFirstReaction(BASE_URL, accessTokens);
try {
const a = (await client.createEmojiReaction(
ctx.params.id,
convertId(ctx.params.id, IdType.CalckeyId),
react,
)) as any;
//const data = await client.favouriteStatus(ctx.params.id) as any;
ctx.body = a.data;
ctx.body = convertStatus(a.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -186,8 +198,11 @@ export function apiStatusMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
const react = await getFirstReaction(BASE_URL, accessTokens);
try {
const data = await client.deleteEmojiReaction(ctx.params.id, react);
ctx.body = data.data;
const data = await client.deleteEmojiReaction(
convertId(ctx.params.id, IdType.CalckeyId),
react
);
ctx.body = convertStatus(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -203,8 +218,10 @@ export function apiStatusMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.reblogStatus(ctx.params.id);
ctx.body = data.data;
const data = await client.reblogStatus(
convertId(ctx.params.id, IdType.CalckeyId)
);
ctx.body = convertStatus(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -220,8 +237,10 @@ export function apiStatusMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unreblogStatus(ctx.params.id);
ctx.body = data.data;
const data = await client.unreblogStatus(
convertId(ctx.params.id, IdType.CalckeyId)
);
ctx.body = convertStatus(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -237,8 +256,10 @@ export function apiStatusMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.bookmarkStatus(ctx.params.id);
ctx.body = data.data;
const data = await client.bookmarkStatus(
convertId(ctx.params.id, IdType.CalckeyId)
);
ctx.body = convertStatus(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -254,8 +275,10 @@ export function apiStatusMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = (await client.unbookmarkStatus(ctx.params.id)) as any;
ctx.body = data.data;
const data = await client.unbookmarkStatus(
convertId(ctx.params.id, IdType.CalckeyId)
);
ctx.body = convertStatus(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -271,8 +294,10 @@ export function apiStatusMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.pinStatus(ctx.params.id);
ctx.body = data.data;
const data = await client.pinStatus(
convertId(ctx.params.id, IdType.CalckeyId)
);
ctx.body = convertStatus(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -288,8 +313,10 @@ export function apiStatusMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.unpinStatus(ctx.params.id);
ctx.body = data.data;
const data = await client.unpinStatus(
convertId(ctx.params.id, IdType.CalckeyId)
);
ctx.body = convertStatus(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -302,8 +329,10 @@ export function apiStatusMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getMedia(ctx.params.id);
ctx.body = data.data;
const data = await client.getMedia(
convertId(ctx.params.id, IdType.CalckeyId)
);
ctx.body = convertAttachment(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -316,10 +345,10 @@ export function apiStatusMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.updateMedia(
ctx.params.id,
convertId(ctx.params.id, IdType.CalckeyId),
ctx.request.body as any,
);
ctx.body = data.data;
ctx.body = convertAttachment(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -331,8 +360,10 @@ export function apiStatusMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getPoll(ctx.params.id);
ctx.body = data.data;
const data = await client.getPoll(
convertId(ctx.params.id, IdType.CalckeyId)
);
ctx.body = convertPoll(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;
@ -347,10 +378,10 @@ export function apiStatusMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.votePoll(
ctx.params.id,
convertId(ctx.params.id, IdType.CalckeyId),
(ctx.request.body as any).choices,
);
ctx.body = data.data;
ctx.body = convertPoll(data.data);
} catch (e: any) {
console.error(e);
ctx.status = 401;

View file

@ -4,6 +4,8 @@ import { getClient } from "../ApiMastodonCompatibleService.js";
import { statusModel } from "./status.js";
import Autolinker from "autolinker";
import { ParsedUrlQuery } from "querystring";
import { convertAccount, convertList, convertStatus } from "../converters.js";
import { convertId, IdType } from "../../index.js";
export function limitToInt(q: ParsedUrlQuery) {
let object: any = q;
@ -29,6 +31,16 @@ export function argsToBools(q: ParsedUrlQuery) {
return q;
}
export function convertTimelinesArgsId(q: ParsedUrlQuery) {
if (typeof q.min_id === "string")
q.min_id = convertId(q.min_id, IdType.CalckeyId);
if (typeof q.max_id === "string")
q.max_id = convertId(q.max_id, IdType.CalckeyId);
if (typeof q.since_id === "string")
q.since_id = convertId(q.since_id, IdType.CalckeyId);
return q;
}
export function toTextWithReaction(status: Entity.Status[], host: string) {
return status.map((t) => {
if (!t) return statusModel(null, null, [], "no content");
@ -97,9 +109,10 @@ export function apiTimelineMastodon(router: Router): void {
try {
const query: any = ctx.query;
const data = query.local
? await client.getLocalTimeline(argsToBools(limitToInt(query)))
: await client.getPublicTimeline(argsToBools(limitToInt(query)));
ctx.body = toTextWithReaction(data.data, ctx.hostname);
? await client.getLocalTimeline(convertTimelinesArgsId(argsToBools(limitToInt(query))))
: await client.getPublicTimeline(convertTimelinesArgsId(argsToBools(limitToInt(query))));
let resp = data.data.map(status => convertStatus(status));
ctx.body = toTextWithReaction(resp, ctx.hostname);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -116,9 +129,10 @@ export function apiTimelineMastodon(router: Router): void {
try {
const data = await client.getTagTimeline(
ctx.params.hashtag,
argsToBools(limitToInt(ctx.query)),
convertTimelinesArgsId(argsToBools(limitToInt(ctx.query))),
);
ctx.body = toTextWithReaction(data.data, ctx.hostname);
let resp = data.data.map(status => convertStatus(status));
ctx.body = toTextWithReaction(resp, ctx.hostname);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -132,8 +146,9 @@ export function apiTimelineMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getHomeTimeline(limitToInt(ctx.query));
ctx.body = toTextWithReaction(data.data, ctx.hostname);
const data = await client.getHomeTimeline(convertTimelinesArgsId(limitToInt(ctx.query)));
let resp = data.data.map(status => convertStatus(status));
ctx.body = toTextWithReaction(resp, ctx.hostname);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -149,10 +164,11 @@ export function apiTimelineMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getListTimeline(
ctx.params.listId,
limitToInt(ctx.query),
convertId(ctx.params.listId, IdType.CalckeyId),
convertTimelinesArgsId(limitToInt(ctx.query)),
);
ctx.body = toTextWithReaction(data.data, ctx.hostname);
let resp = data.data.map(status => convertStatus(status));
ctx.body = toTextWithReaction(resp, ctx.hostname);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -166,7 +182,7 @@ export function apiTimelineMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getConversationTimeline(limitToInt(ctx.query));
const data = await client.getConversationTimeline(convertTimelinesArgsId(limitToInt(ctx.query)));
ctx.body = data.data;
} catch (e: any) {
console.error(e);
@ -181,7 +197,7 @@ export function apiTimelineMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getLists();
ctx.body = data.data;
ctx.body = data.data.map(list => convertList(list));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -196,8 +212,10 @@ export function apiTimelineMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getList(ctx.params.id);
ctx.body = data.data;
const data = await client.getList(
convertId(ctx.params.id, IdType.CalckeyId),
);
ctx.body = convertList(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -212,7 +230,7 @@ export function apiTimelineMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.createList((ctx.request.body as any).title);
ctx.body = data.data;
ctx.body = convertList(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -227,8 +245,11 @@ export function apiTimelineMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.updateList(ctx.params.id, (ctx.request.body as any).title);
ctx.body = data.data;
const data = await client.updateList(
convertId(ctx.params.id, IdType.CalckeyId),
(ctx.request.body as any).title
);
ctx.body = convertList(data.data);
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -244,7 +265,9 @@ export function apiTimelineMastodon(router: Router): void {
const accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteList(ctx.params.id);
const data = await client.deleteList(
convertId(ctx.params.id, IdType.CalckeyId),
);
ctx.body = data.data;
} catch (e: any) {
console.error(e);
@ -262,10 +285,10 @@ export function apiTimelineMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.getAccountsInList(
ctx.params.id,
ctx.query as any,
convertId(ctx.params.id, IdType.CalckeyId),
convertTimelinesArgsId(ctx.query as any),
);
ctx.body = data.data;
ctx.body = data.data.map(account => convertAccount(account));
} catch (e: any) {
console.error(e);
console.error(e.response.data);
@ -282,8 +305,8 @@ export function apiTimelineMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.addAccountsToList(
ctx.params.id,
(ctx.query as any).account_ids,
convertId(ctx.params.id, IdType.CalckeyId),
(ctx.query.account_ids as string[]).map(id => convertId(id, IdType.CalckeyId)),
);
ctx.body = data.data;
} catch (e: any) {
@ -302,8 +325,8 @@ export function apiTimelineMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens);
try {
const data = await client.deleteAccountsFromList(
ctx.params.id,
(ctx.query as any).account_ids,
convertId(ctx.params.id, IdType.CalckeyId),
(ctx.query.account_ids as string[]).map(id => convertId(id, IdType.CalckeyId)),
);
ctx.body = data.data;
} catch (e: any) {

View file

@ -55,7 +55,7 @@ export default async (ctx: Koa.Context) => {
return;
}
const available = await validateEmailForAccount(emailAddress);
const { available } = await validateEmailForAccount(emailAddress);
if (!available) {
ctx.status = 400;
return;

View file

@ -399,28 +399,31 @@ router.get("/notes/:note", async (ctx, next) => {
visibility: In(["public", "home"]),
});
if (note) {
const _note = await Notes.pack(note);
const profile = await UserProfiles.findOneByOrFail({ userId: note.userId });
const meta = await fetchMeta();
await ctx.render("note", {
note: _note,
profile,
avatarUrl: await Users.getAvatarUrl(
await Users.findOneByOrFail({ id: note.userId }),
),
// TODO: Let locale changeable by instance setting
summary: getNoteSummary(_note),
instanceName: meta.name || "Calckey",
icon: meta.iconUrl,
privateMode: meta.privateMode,
themeColor: meta.themeColor,
});
try {
if (note) {
const _note = await Notes.pack(note);
const profile = await UserProfiles.findOneByOrFail({ userId: note.userId });
const meta = await fetchMeta();
await ctx.render("note", {
note: _note,
profile,
avatarUrl: await Users.getAvatarUrl(
await Users.findOneByOrFail({ id: note.userId }),
),
// TODO: Let locale changeable by instance setting
summary: getNoteSummary(_note),
instanceName: meta.name || "Calckey",
icon: meta.iconUrl,
privateMode: meta.privateMode,
themeColor: meta.themeColor,
});
ctx.set("Cache-Control", "public, max-age=15");
ctx.set("Cache-Control", "public, max-age=15");
return;
}
return;
}
} catch {}
await next();
});

View file

@ -6,11 +6,13 @@ import {
NoteThreadMutings,
UserProfiles,
Users,
Followings,
} from "@/models/index.js";
import { genId } from "@/misc/gen-id.js";
import type { User } from "@/models/entities/user.js";
import type { Notification } from "@/models/entities/notification.js";
import { sendEmailNotification } from "./send-email-notification.js";
import { shouldSilenceInstance } from "@/misc/should-block-instance.js";
export async function createNotification(
notifieeId: User["id"],
@ -21,6 +23,26 @@ export async function createNotification(
return null;
}
if (
data.notifierId &&
["mention", "reply", "renote", "quote", "reaction"].includes(type)
) {
const notifier = await Users.findOneBy({ id: data.notifierId });
// suppress if the notifier does not exist or is silenced.
if (!notifier) return null;
// suppress if the notifier is silenced or in a silenced instance, and not followed by the notifiee.
if (
(notifier.isSilenced ||
(Users.isRemoteUser(notifier) &&
(await shouldSilenceInstance(notifier.host)))) &&
!(await Followings.exist({
where: { followerId: notifieeId, followeeId: data.notifierId },
}))
)
return null;
}
const profile = await UserProfiles.findOneBy({ userId: notifieeId });
const isMuted = profile?.mutingNotificationTypes.includes(type);

View file

@ -27,6 +27,7 @@ import { isDuplicateKeyValueError } from "@/misc/is-duplicate-key-value-error.js
import type { Packed } from "@/misc/schema.js";
import { getActiveWebhooks } from "@/misc/webhook-cache.js";
import { webhookDeliver } from "@/queue/index.js";
import { shouldSilenceInstance } from "@/misc/should-block-instance.js";
const logger = new Logger("following/create");
@ -226,13 +227,19 @@ export default async function (
});
// フォロー対象が鍵アカウントである or
// The follower is silenced, or
// フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
// フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである
// フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである or
// The follower is remote, the followee is local, and the follower is in a silenced instance.
// 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく
if (
followee.isLocked ||
follower.isSilenced ||
(followeeProfile.carefulBot && follower.isBot) ||
(Users.isLocalUser(follower) && Users.isRemoteUser(followee))
(Users.isLocalUser(follower) && Users.isRemoteUser(followee)) ||
(Users.isRemoteUser(follower) &&
Users.isLocalUser(followee) &&
(await shouldSilenceInstance(follower.host)))
) {
let autoAccept = false;

View file

@ -6,6 +6,7 @@ import type { User } from "@/models/entities/user.js";
import { Blockings, FollowRequests, Users } from "@/models/index.js";
import { genId } from "@/misc/gen-id.js";
import { createNotification } from "../../create-notification.js";
import config from "@/config/index.js";
export default async function (
follower: {
@ -79,7 +80,13 @@ export default async function (
}
if (Users.isLocalUser(follower) && Users.isRemoteUser(followee)) {
const content = renderActivity(renderFollow(follower, followee));
const content = renderActivity(
renderFollow(
follower,
followee,
requestId ?? `${config.url}/follows/${followRequest.id}`,
),
);
deliver(follower, content, followee.inbox);
}
}

View file

@ -39,7 +39,7 @@ import {
} from "@/models/index.js";
import type { DriveFile } from "@/models/entities/drive-file.js";
import type { App } from "@/models/entities/app.js";
import { Not, In } from "typeorm";
import { Not, In, IsNull } from "typeorm";
import type { User, ILocalUser, IRemoteUser } from "@/models/entities/user.js";
import { genId } from "@/misc/gen-id.js";
import {
@ -66,6 +66,7 @@ import { Cache } from "@/misc/cache.js";
import type { UserProfile } from "@/models/entities/user-profile.js";
import { db } from "@/db/postgre.js";
import { getActiveWebhooks } from "@/misc/webhook-cache.js";
import { shouldSilenceInstance } from "@/misc/should-block-instance.js";
const mutedWordsCache = new Cache<
{ userId: UserProfile["userId"]; mutedWords: UserProfile["mutedWords"] }[]
@ -166,6 +167,7 @@ export default async (
data: Option,
silent = false,
) =>
// rome-ignore lint/suspicious/noAsyncPromiseExecutor: FIXME
new Promise<Note>(async (res, rej) => {
// If you reply outside the channel, match the scope of the target.
// TODO (I think it's a process that could be done on the client side, but it's server side for now.)
@ -203,6 +205,15 @@ export default async (
data.visibility = "home";
}
// Enforce home visibility if the user is in a silenced instance.
if (
data.visibility === "public" &&
Users.isRemoteUser(user) &&
(await shouldSilenceInstance(user.host))
) {
data.visibility = "home";
}
// Reject if the target of the renote is a public range other than "Home or Entire".
if (
data.renote &&

View file

@ -118,7 +118,7 @@ export default async (
userId: user.id,
});
// リアクションされたユーザーがローカルユーザーなら通知を作成
// Create notification if the reaction target is a local user.
if (note.userHost === null) {
createNotification(note.userId, "reaction", {
notifierId: user.id,
@ -143,7 +143,7 @@ export default async (
}
});
//#region 配信
//#region deliver
if (Users.isLocalUser(user) && !note.localOnly) {
const content = renderActivity(await renderLike(record, note));
const dm = new DeliverManager(user, content);

View file

@ -1,7 +0,0 @@
node_modules
/built
/coverage
/.eslintrc.js
/jest.config.ts
/test
/test-d

View file

@ -1,65 +0,0 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
parserOptions: {
tsconfigRootDir: __dirname,
project: ["./tsconfig.json"],
},
plugins: ["@typescript-eslint"],
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
rules: {
indent: [
"error",
"tab",
{
SwitchCase: 1,
MemberExpression: "off",
flatTernaryExpressions: true,
ArrayExpression: "first",
ObjectExpression: "first",
},
],
"eol-last": ["error", "always"],
semi: ["error", "always"],
quotes: ["error", "single"],
"comma-dangle": ["error", "always-multiline"],
"keyword-spacing": [
"error",
{
before: true,
after: true,
},
],
"key-spacing": [
"error",
{
beforeColon: false,
afterColon: true,
},
],
"space-infix-ops": ["error"],
"space-before-blocks": ["error", "always"],
"object-curly-spacing": ["error", "always"],
"nonblock-statement-body-position": ["error", "beside"],
eqeqeq: ["error", "always", { null: "ignore" }],
"no-multiple-empty-lines": ["error", { max: 1 }],
"no-multi-spaces": ["error"],
"no-var": ["error"],
"prefer-arrow-callback": ["error"],
"no-throw-literal": ["error"],
"no-param-reassign": ["warn"],
"no-constant-condition": ["warn"],
"no-empty-pattern": ["warn"],
"@typescript-eslint/no-unnecessary-condition": ["error"],
"@typescript-eslint/no-inferrable-types": ["warn"],
"@typescript-eslint/no-non-null-assertion": ["warn"],
"@typescript-eslint/explicit-function-return-type": ["warn"],
"@typescript-eslint/no-misused-promises": [
"error",
{
checksVoidReturn: false,
},
],
"@typescript-eslint/consistent-type-imports": "error",
},
};

View file

@ -9,9 +9,8 @@
"tsd": "tsd",
"api": "pnpm api-extractor run --local --verbose",
"api-prod": "pnpm api-extractor run --verbose",
"eslint": "eslint . --ext .js,.jsx,.ts,.tsx",
"typecheck": "tsc --noEmit",
"lint": "pnpm typecheck && pnpm eslint",
"lint": "pnpm typecheck && pnpm rome check \"src/*.ts\"",
"jest": "jest --coverage --detectOpenHandles",
"test": "pnpm jest && pnpm tsd"
},

View file

@ -55,6 +55,7 @@ export type Endpoints = {
"admin/get-table-stats": { req: TODO; res: TODO };
"admin/invite": { req: TODO; res: TODO };
"admin/logs": { req: TODO; res: TODO };
"admin/meta": { req: TODO; res: TODO };
"admin/reset-password": { req: TODO; res: TODO };
"admin/resolve-abuse-user-report": { req: TODO; res: TODO };
"admin/resync-chart": { req: TODO; res: TODO };

View file

@ -32,7 +32,7 @@
"autosize": "5.0.2",
"blurhash": "1.1.5",
"broadcast-channel": "4.19.1",
"browser-image-resizer": "https://github.com/misskey-dev/browser-image-resizer.git",
"browser-image-resizer": "github:misskey-dev/browser-image-resizer",
"calckey-js": "workspace:*",
"chart.js": "4.1.1",
"chartjs-adapter-date-fns": "2.0.1",

View file

@ -195,8 +195,7 @@ function onMousedown(evt: MouseEvent): void {
}
&:focus-visible {
outline: solid 2px var(--focus);
outline-offset: 2px;
outline: auto;
}
&.inline {

View file

@ -1,5 +1,6 @@
<template>
<button
ref="el"
class="_button"
:class="{ showLess: modelValue, fade: !modelValue }"
@click.stop="toggle"
@ -12,7 +13,7 @@
</template>
<script lang="ts" setup>
import { computed } from "vue";
import { computed, ref } from "vue";
import { length } from "stringz";
import * as misskey from "calckey-js";
import { concat } from "@/scripts/array";
@ -27,6 +28,8 @@ const emit = defineEmits<{
(ev: "update:modelValue", v: boolean): void;
}>();
const el = ref<HTMLElement>();
const label = computed(() => {
return concat([
props.note.text
@ -43,6 +46,14 @@ const label = computed(() => {
const toggle = () => {
emit("update:modelValue", !props.modelValue);
};
function focus() {
el.value.focus();
}
defineExpose({
focus,
});
</script>
<style lang="scss" scoped>
@ -62,7 +73,8 @@ const toggle = () => {
}
}
}
&:hover > span {
&:hover > span,
&:focus > span {
background: var(--cwFg) !important;
color: var(--cwBg) !important;
}
@ -73,6 +85,7 @@ const toggle = () => {
bottom: 0;
left: 0;
width: 100%;
z-index: 2;
> span {
display: inline-block;
background: var(--panel);
@ -81,7 +94,8 @@ const toggle = () => {
border-radius: 999px;
box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
}
&:hover {
&:hover,
&:focus {
> span {
background: var(--panelHighlight);
}

View file

@ -1,5 +1,5 @@
<template>
<div ref="thumbnail" class="zdjebgpv">
<button ref="thumbnail" class="zdjebgpv">
<ImgWithBlurhash
v-if="isThumbnailAvailable"
:hash="file.blurhash"
@ -36,7 +36,7 @@
v-if="isThumbnailAvailable && is === 'video'"
class="ph-file-video ph-bold ph-lg icon-sub"
></i>
</div>
</button>
</template>
<script lang="ts" setup>
@ -88,6 +88,9 @@ const isThumbnailAvailable = computed(() => {
background: var(--panel);
border-radius: 8px;
overflow: clip;
border: 0;
padding: 0;
cursor: pointer;
> .icon-sub {
position: absolute;

View file

@ -1,157 +1,160 @@
<template>
<div
class="omfetrab"
:class="['s' + size, 'w' + width, 'h' + height, { asDrawer }]"
:style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }"
>
<input
ref="search"
v-model.trim="q"
class="search"
data-prevent-emoji-insert
:class="{ filled: q != null && q != '' }"
:placeholder="i18n.ts.search"
type="search"
@paste.stop="paste"
@keyup.enter="done()"
/>
<div ref="emojis" class="emojis">
<section class="result">
<div v-if="searchResultCustom.length > 0" class="body">
<button
v-for="emoji in searchResultCustom"
:key="emoji.id"
class="_button item"
:title="emoji.name"
tabindex="0"
@click="chosen(emoji, $event)"
>
<!--<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>-->
<img
class="emoji"
:src="
disableShowingAnimatedImages
? getStaticImageUrl(emoji.url)
: emoji.url
"
/>
</button>
</div>
<div v-if="searchResultUnicode.length > 0" class="body">
<button
v-for="emoji in searchResultUnicode"
:key="emoji.name"
class="_button item"
:title="emoji.name"
tabindex="0"
@click="chosen(emoji, $event)"
>
<MkEmoji class="emoji" :emoji="emoji.char" />
</button>
</div>
</section>
<div v-if="tab === 'index'" class="group index">
<section v-if="showPinned">
<div class="body">
<FocusTrap v-bind:active="isActive">
<div
class="omfetrab"
:class="['s' + size, 'w' + width, 'h' + height, { asDrawer }]"
:style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }"
tabindex="-1"
>
<input
ref="search"
v-model.trim="q"
class="search"
data-prevent-emoji-insert
:class="{ filled: q != null && q != '' }"
:placeholder="i18n.ts.search"
type="search"
@paste.stop="paste"
@keyup.enter="done()"
/>
<div ref="emojis" class="emojis">
<section class="result">
<div v-if="searchResultCustom.length > 0" class="body">
<button
v-for="emoji in pinned"
:key="emoji"
v-for="emoji in searchResultCustom"
:key="emoji.id"
class="_button item"
:title="emoji.name"
tabindex="0"
@click="chosen(emoji, $event)"
>
<MkEmoji
<!--<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>-->
<img
class="emoji"
:emoji="emoji"
:normal="true"
:src="
disableShowingAnimatedImages
? getStaticImageUrl(emoji.url)
: emoji.url
"
/>
</button>
</div>
<div v-if="searchResultUnicode.length > 0" class="body">
<button
v-for="emoji in searchResultUnicode"
:key="emoji.name"
class="_button item"
:title="emoji.name"
tabindex="0"
@click="chosen(emoji, $event)"
>
<MkEmoji class="emoji" :emoji="emoji.char" />
</button>
</div>
</section>
<section>
<header class="_acrylic">
<i class="ph-alarm ph-bold ph-fw ph-lg"></i>
{{ i18n.ts.recentUsed }}
</header>
<div class="body">
<button
v-for="emoji in recentlyUsedEmojis"
:key="emoji"
class="_button item"
@click="chosen(emoji, $event)"
>
<MkEmoji
class="emoji"
:emoji="emoji"
:normal="true"
/>
</button>
</div>
</section>
<div v-if="tab === 'index'" class="group index">
<section v-if="showPinned">
<div class="body">
<button
v-for="emoji in pinned"
:key="emoji"
class="_button item"
tabindex="0"
@click="chosen(emoji, $event)"
>
<MkEmoji
class="emoji"
:emoji="emoji"
:normal="true"
/>
</button>
</div>
</section>
<section>
<header class="_acrylic">
<i class="ph-alarm ph-bold ph-fw ph-lg"></i>
{{ i18n.ts.recentUsed }}
</header>
<div class="body">
<button
v-for="emoji in recentlyUsedEmojis"
:key="emoji"
class="_button item"
@click="chosen(emoji, $event)"
>
<MkEmoji
class="emoji"
:emoji="emoji"
:normal="true"
/>
</button>
</div>
</section>
</div>
<div v-once class="group">
<header>{{ i18n.ts.customEmojis }}</header>
<XSection
v-for="category in customEmojiCategories"
:key="'custom:' + category"
:initial-shown="false"
:emojis="
customEmojis
.filter((e) => e.category === category)
.map((e) => ':' + e.name + ':')
"
@chosen="chosen"
>{{ category || i18n.ts.other }}</XSection
>
</div>
<div v-once class="group">
<header>{{ i18n.ts.emoji }}</header>
<XSection
v-for="category in categories"
:key="category"
:emojis="
emojilist
.filter((e) => e.category === category)
.map((e) => e.char)
"
@chosen="chosen"
>{{ category }}</XSection
>
</div>
</div>
<div v-once class="group">
<header>{{ i18n.ts.customEmojis }}</header>
<XSection
v-for="category in customEmojiCategories"
:key="'custom:' + category"
:initial-shown="false"
:emojis="
customEmojis
.filter((e) => e.category === category)
.map((e) => ':' + e.name + ':')
"
@chosen="chosen"
>{{ category || i18n.ts.other }}</XSection
<div class="tabs">
<button
class="_button tab"
:class="{ active: tab === 'index' }"
@click="tab = 'index'"
>
</div>
<div v-once class="group">
<header>{{ i18n.ts.emoji }}</header>
<XSection
v-for="category in categories"
:key="category"
:emojis="
emojilist
.filter((e) => e.category === category)
.map((e) => e.char)
"
@chosen="chosen"
>{{ category }}</XSection
<i class="ph-asterisk ph-bold ph-lg ph-fw ph-lg"></i>
</button>
<button
class="_button tab"
:class="{ active: tab === 'custom' }"
@click="tab = 'custom'"
>
<i class="ph-smiley ph-bold ph-lg ph-fw ph-lg"></i>
</button>
<button
class="_button tab"
:class="{ active: tab === 'unicode' }"
@click="tab = 'unicode'"
>
<i class="ph-leaf ph-bold ph-lg ph-fw ph-lg"></i>
</button>
<button
class="_button tab"
:class="{ active: tab === 'tags' }"
@click="tab = 'tags'"
>
<i class="ph-hash ph-bold ph-lg ph-fw ph-lg"></i>
</button>
</div>
</div>
<div class="tabs">
<button
class="_button tab"
:class="{ active: tab === 'index' }"
@click="tab = 'index'"
>
<i class="ph-asterisk ph-bold ph-lg ph-fw ph-lg"></i>
</button>
<button
class="_button tab"
:class="{ active: tab === 'custom' }"
@click="tab = 'custom'"
>
<i class="ph-smiley ph-bold ph-lg ph-fw ph-lg"></i>
</button>
<button
class="_button tab"
:class="{ active: tab === 'unicode' }"
@click="tab = 'unicode'"
>
<i class="ph-leaf ph-bold ph-lg ph-fw ph-lg"></i>
</button>
<button
class="_button tab"
:class="{ active: tab === 'tags' }"
@click="tab = 'tags'"
>
<i class="ph-hash ph-bold ph-lg ph-fw ph-lg"></i>
</button>
</div>
</div>
</FocusTrap>
</template>
<script lang="ts" setup>
@ -171,6 +174,7 @@ import { deviceKind } from "@/scripts/device-kind";
import { emojiCategories, instance } from "@/instance";
import { i18n } from "@/i18n";
import { defaultStore } from "@/store";
import { FocusTrap } from "focus-trap-vue";
const props = withDefaults(
defineProps<{

View file

@ -20,9 +20,12 @@ export default defineComponent({
},
computed: {
compiledFormula(): any {
return katex.renderToString(this.formula, {
const katexString = katex.renderToString(this.formula, {
throwOnError: false,
} as any);
return this.block
? `<div style="text-align:center">${katexString}</div>`
: katexString;
},
},
});

View file

@ -5,6 +5,7 @@
{
yellow: instance.isNotResponding,
red: instance.isBlocked,
purple: instance.isSilenced,
gray: instance.isSuspended,
},
]"
@ -23,13 +24,13 @@
</template>
<script lang="ts" setup>
import * as misskey from "calckey-js";
import * as calckey from "calckey-js";
import MkMiniChart from "@/components/MkMiniChart.vue";
import * as os from "@/os";
import { getProxiedImageUrlNullable } from "@/scripts/media-proxy";
const props = defineProps<{
instance: misskey.entities.Instance;
instance: calckey.entities.Instance;
}>();
let chartValues = $ref<number[] | null>(null);
@ -135,6 +136,21 @@ function getInstanceIcon(instance): string {
background-size: 16px 16px;
}
&:global(.purple) {
--c: rgba(196, 0, 255, 0.15);
background-image: linear-gradient(
45deg,
var(--c) 16.67%,
transparent 16.67%,
transparent 50%,
var(--c) 50%,
var(--c) 66.67%,
transparent 66.67%,
transparent 100%
);
background-size: 16px 16px;
}
&:global(.gray) {
--c: var(--bg);
background-image: linear-gradient(

View file

@ -139,7 +139,8 @@ function close() {
height: 100px;
border-radius: 10px;
&:hover {
&:hover,
&:focus-visible {
color: var(--accent);
background: var(--accentedBg);
text-decoration: none;

View file

@ -138,6 +138,10 @@ watch(
background-position: center;
background-size: contain;
background-repeat: no-repeat;
box-sizing: border-box;
&:focus-visible {
border: 2px solid var(--accent);
}
> .gif {
background-color: var(--fg);

View file

@ -1,5 +1,5 @@
<template>
<div ref="el" class="sfhdhdhr">
<div ref="el" class="sfhdhdhr" tabindex="-1">
<MkMenu
ref="menu"
:items="items"
@ -23,7 +23,6 @@ import {
} from "vue";
import MkMenu from "./MkMenu.vue";
import { MenuItem } from "@/types/menu";
import * as os from "@/os";
const props = defineProps<{
items: MenuItem[];

View file

@ -1,191 +1,202 @@
<template>
<div>
<div
ref="itemsEl"
v-hotkey="keymap"
class="rrevdjwt _popup _shadow"
:class="{ center: align === 'center', asDrawer }"
:style="{
width: width && !asDrawer ? width + 'px' : '',
maxHeight: maxHeight ? maxHeight + 'px' : '',
}"
@contextmenu.self="(e) => e.preventDefault()"
>
<template v-for="(item, i) in items2">
<div v-if="item === null" class="divider"></div>
<span v-else-if="item.type === 'label'" class="label item">
<span :style="item.textStyle || ''">{{ item.text }}</span>
</span>
<span
v-else-if="item.type === 'pending'"
:tabindex="i"
class="pending item"
>
<span><MkEllipsis /></span>
</span>
<MkA
v-else-if="item.type === 'link'"
:to="item.to"
:tabindex="i"
class="_button item"
@click.passive="close(true)"
@mouseenter.passive="onItemMouseEnter(item)"
@mouseleave.passive="onItemMouseLeave(item)"
>
<i
v-if="item.icon"
class="ph-fw ph-lg"
:class="item.icon"
></i>
<span v-else-if="item.icons">
<i
v-for="icon in item.icons"
class="ph-fw ph-lg"
:class="icon"
></i>
<FocusTrap :active="false" ref="focusTrap">
<div tabindex="-1">
<div
ref="itemsEl"
class="rrevdjwt _popup _shadow"
:class="{ center: align === 'center', asDrawer }"
:style="{
width: width && !asDrawer ? width + 'px' : '',
maxHeight: maxHeight ? maxHeight + 'px' : '',
}"
@contextmenu.self="(e) => e.preventDefault()"
>
<template v-for="(item, i) in items2">
<div v-if="item === null" class="divider"></div>
<span v-else-if="item.type === 'label'" class="label item">
<span :style="item.textStyle || ''">{{
item.text
}}</span>
</span>
<MkAvatar
v-if="item.avatar"
:user="item.avatar"
class="avatar"
/>
<span :style="item.textStyle || ''">{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"
><i class="ph-circle ph-fill"></i
></span>
</MkA>
<a
v-else-if="item.type === 'a'"
:href="item.href"
:target="item.target"
:download="item.download"
:tabindex="i"
class="_button item"
@click="close(true)"
@mouseenter.passive="onItemMouseEnter(item)"
@mouseleave.passive="onItemMouseLeave(item)"
>
<i
v-if="item.icon"
class="ph-fw ph-lg"
:class="item.icon"
></i>
<span v-else-if="item.icons">
<i
v-for="icon in item.icons"
class="ph-fw ph-lg"
:class="icon"
></i>
</span>
<span :style="item.textStyle || ''">{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"
><i class="ph-circle ph-fill"></i
></span>
</a>
<button
v-else-if="item.type === 'user' && !items.hidden"
:tabindex="i"
class="_button item"
:class="{ active: item.active }"
:disabled="item.active"
@click="clicked(item.action, $event)"
@mouseenter.passive="onItemMouseEnter(item)"
@mouseleave.passive="onItemMouseLeave(item)"
>
<MkAvatar :user="item.user" class="avatar" /><MkUserName
:user="item.user"
/>
<span v-if="item.indicate" class="indicator"
><i class="ph-circle ph-fill"></i
></span>
</button>
<span
v-else-if="item.type === 'switch'"
:tabindex="i"
class="item"
@mouseenter.passive="onItemMouseEnter(item)"
@mouseleave.passive="onItemMouseLeave(item)"
>
<FormSwitch
v-model="item.ref"
:disabled="item.disabled"
class="form-switch"
:style="item.textStyle || ''"
>{{ item.text }}</FormSwitch
<span
v-else-if="item.type === 'pending'"
class="pending item"
>
<span><MkEllipsis /></span>
</span>
<MkA
v-else-if="item.type === 'link'"
:to="item.to"
class="_button item"
@click.passive="close(true)"
@mouseenter.passive="onItemMouseEnter(item)"
@mouseleave.passive="onItemMouseLeave(item)"
>
<i
v-if="item.icon"
class="ph-fw ph-lg"
:class="item.icon"
></i>
<span v-else-if="item.icons">
<i
v-for="icon in item.icons"
class="ph-fw ph-lg"
:class="icon"
></i>
</span>
<MkAvatar
v-if="item.avatar"
:user="item.avatar"
class="avatar"
disableLink
/>
<span :style="item.textStyle || ''">{{
item.text
}}</span>
<span v-if="item.indicate" class="indicator"
><i class="ph-circle ph-fill"></i
></span>
</MkA>
<a
v-else-if="item.type === 'a'"
:href="item.href"
:target="item.target"
:download="item.download"
class="_button item"
@click="close(true)"
@mouseenter.passive="onItemMouseEnter(item)"
@mouseleave.passive="onItemMouseLeave(item)"
>
<i
v-if="item.icon"
class="ph-fw ph-lg"
:class="item.icon"
></i>
<span v-else-if="item.icons">
<i
v-for="icon in item.icons"
class="ph-fw ph-lg"
:class="icon"
></i>
</span>
<span :style="item.textStyle || ''">{{
item.text
}}</span>
<span v-if="item.indicate" class="indicator"
><i class="ph-circle ph-fill"></i
></span>
</a>
<button
v-else-if="item.type === 'user' && !items.hidden"
class="_button item"
:class="{ active: item.active }"
:disabled="item.active"
@click="clicked(item.action, $event)"
@mouseenter.passive="onItemMouseEnter(item)"
@mouseleave.passive="onItemMouseLeave(item)"
>
<MkAvatar
:user="item.user"
class="avatar"
disableLink
/><MkUserName :user="item.user" />
<span v-if="item.indicate" class="indicator"
><i class="ph-circle ph-fill"></i
></span>
</button>
<span
v-else-if="item.type === 'switch'"
class="item"
@mouseenter.passive="onItemMouseEnter(item)"
@mouseleave.passive="onItemMouseLeave(item)"
>
<FormSwitch
v-model="item.ref"
:disabled="item.disabled"
class="form-switch"
:style="item.textStyle || ''"
>{{ item.text }}</FormSwitch
>
</span>
<button
v-else-if="item.type === 'parent'"
class="_button item parent"
:class="{ childShowing: childShowingItem === item }"
@mouseenter="showChildren(item, $event)"
@click="showChildren(item, $event)"
>
<i
v-if="item.icon"
class="ph-fw ph-lg"
:class="item.icon"
></i>
<span v-else-if="item.icons">
<i
v-for="icon in item.icons"
class="ph-fw ph-lg"
:class="icon"
></i>
</span>
<span :style="item.textStyle || ''">{{
item.text
}}</span>
<span class="caret"
><i
class="ph-caret-right ph-bold ph-lg ph-fw ph-lg"
></i
></span>
</button>
<button
v-else-if="!item.hidden"
class="_button item"
:class="{ danger: item.danger, active: item.active }"
:disabled="item.active"
@click="clicked(item.action, $event)"
@mouseenter.passive="onItemMouseEnter(item)"
@mouseleave.passive="onItemMouseLeave(item)"
>
<i
v-if="item.icon"
class="ph-fw ph-lg"
:class="item.icon"
></i>
<span v-else-if="item.icons">
<i
v-for="icon in item.icons"
class="ph-fw ph-lg"
:class="icon"
></i>
</span>
<MkAvatar
v-if="item.avatar"
:user="item.avatar"
class="avatar"
disableLink
/>
<span :style="item.textStyle || ''">{{
item.text
}}</span>
<span v-if="item.indicate" class="indicator"
><i class="ph-circle ph-fill"></i
></span>
</button>
</template>
<span v-if="items2.length === 0" class="none item">
<span>{{ i18n.ts.none }}</span>
</span>
<button
v-else-if="item.type === 'parent'"
:tabindex="i"
class="_button item parent"
:class="{ childShowing: childShowingItem === item }"
@mouseenter="showChildren(item, $event)"
>
<i
v-if="item.icon"
class="ph-fw ph-lg"
:class="item.icon"
></i>
<span v-else-if="item.icons">
<i
v-for="icon in item.icons"
class="ph-fw ph-lg"
:class="icon"
></i>
</span>
<span :style="item.textStyle || ''">{{ item.text }}</span>
<span class="caret"
><i class="ph-caret-right ph-bold ph-lg ph-fw ph-lg"></i
></span>
</button>
<button
v-else-if="!item.hidden"
:tabindex="i"
class="_button item"
:class="{ danger: item.danger, active: item.active }"
:disabled="item.active"
@click="clicked(item.action, $event)"
@mouseenter.passive="onItemMouseEnter(item)"
@mouseleave.passive="onItemMouseLeave(item)"
>
<i
v-if="item.icon"
class="ph-fw ph-lg"
:class="item.icon"
></i>
<span v-else-if="item.icons">
<i
v-for="icon in item.icons"
class="ph-fw ph-lg"
:class="icon"
></i>
</span>
<MkAvatar
v-if="item.avatar"
:user="item.avatar"
class="avatar"
/>
<span :style="item.textStyle || ''">{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"
><i class="ph-circle ph-fill"></i
></span>
</button>
</template>
<span v-if="items2.length === 0" class="none item">
<span>{{ i18n.ts.none }}</span>
</span>
</div>
<div v-if="childMenu" class="child">
<XChild
ref="child"
:items="childMenu"
:target-element="childTarget"
:root-element="itemsEl"
showing
@actioned="childActioned"
/>
</div>
</div>
<div v-if="childMenu" class="child">
<XChild
ref="child"
:items="childMenu"
:target-element="childTarget"
:root-element="itemsEl"
showing
@actioned="childActioned"
/>
</div>
</div>
</FocusTrap>
</template>
<script lang="ts" setup>
@ -206,8 +217,10 @@ import FormSwitch from "@/components/form/switch.vue";
import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from "@/types/menu";
import * as os from "@/os";
import { i18n } from "@/i18n";
import { FocusTrap } from "focus-trap-vue";
const XChild = defineAsyncComponent(() => import("./MkMenu.child.vue"));
const focusTrap = ref();
const props = defineProps<{
items: MenuItem[];
@ -228,12 +241,6 @@ let items2: InnerMenuItem[] = $ref([]);
let child = $ref<InstanceType<typeof XChild>>();
let keymap = computed(() => ({
"up|k|shift+tab": focusUp,
"down|j|tab": focusDown,
esc: close,
}));
let childShowingItem = $ref<MenuItem | null>();
watch(
@ -324,6 +331,8 @@ function focusDown() {
}
onMounted(() => {
focusTrap.value.activate();
if (props.viaKeyboard) {
nextTick(() => {
focusNext(itemsEl.children[0], true, false);
@ -364,8 +373,7 @@ onBeforeUnmount(() => {
font-size: 0.9em;
line-height: 20px;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
outline: none;
&:before {
content: "";
@ -389,7 +397,8 @@ onBeforeUnmount(() => {
transform: translateY(0em);
}
&:not(:disabled):hover {
&:not(:disabled):hover,
&:focus-visible {
color: var(--accent);
text-decoration: none;
@ -397,6 +406,9 @@ onBeforeUnmount(() => {
background: var(--accentedBg);
}
}
&:focus-visible:before {
outline: auto;
}
&.danger {
color: #eb6f92;

View file

@ -14,54 +14,62 @@
:duration="transitionDuration"
appear
@after-leave="emit('closed')"
@keyup.esc="emit('click')"
@enter="emit('opening')"
@after-enter="onOpened"
>
<div
v-show="manualShowing != null ? manualShowing : showing"
v-hotkey.global="keymap"
:class="[
$style.root,
{
[$style.drawer]: type === 'drawer',
[$style.dialog]: type === 'dialog' || type === 'dialog:top',
[$style.popup]: type === 'popup',
},
]"
:style="{
zIndex,
pointerEvents: (manualShowing != null ? manualShowing : showing)
? 'auto'
: 'none',
'--transformOrigin': transformOrigin,
}"
>
<FocusTrap v-model:active="isActive">
<div
class="_modalBg data-cy-bg"
v-show="manualShowing != null ? manualShowing : showing"
v-hotkey.global="keymap"
:class="[
$style.bg,
$style.root,
{
[$style.bgTransparent]: isEnableBgTransparent,
'data-cy-transparent': isEnableBgTransparent,
[$style.drawer]: type === 'drawer',
[$style.dialog]:
type === 'dialog' || type === 'dialog:top',
[$style.popup]: type === 'popup',
},
]"
:style="{ zIndex }"
@click="onBgClick"
@mousedown="onBgClick"
@contextmenu.prevent.stop="() => {}"
></div>
<div
ref="content"
:class="[
$style.content,
{ [$style.fixed]: fixed, top: type === 'dialog:top' },
]"
:style="{ zIndex }"
@click.self="onBgClick"
:style="{
zIndex,
pointerEvents: (
manualShowing != null ? manualShowing : showing
)
? 'auto'
: 'none',
'--transformOrigin': transformOrigin,
}"
tabindex="-1"
v-focus
>
<slot :max-height="maxHeight" :type="type"></slot>
<div
class="_modalBg data-cy-bg"
:class="[
$style.bg,
{
[$style.bgTransparent]: isEnableBgTransparent,
'data-cy-transparent': isEnableBgTransparent,
},
]"
:style="{ zIndex }"
@click="onBgClick"
@mousedown="onBgClick"
@contextmenu.prevent.stop="() => {}"
></div>
<div
ref="content"
:class="[
$style.content,
{ [$style.fixed]: fixed, top: type === 'dialog:top' },
]"
:style="{ zIndex }"
@click.self="onBgClick"
>
<slot :max-height="maxHeight" :type="type"></slot>
</div>
</div>
</div>
</FocusTrap>
</Transition>
</template>
@ -71,6 +79,7 @@ import * as os from "@/os";
import { isTouchUsing } from "@/scripts/touch";
import { defaultStore } from "@/store";
import { deviceKind } from "@/scripts/device-kind";
import { FocusTrap } from "focus-trap-vue";
function getFixedContainer(el: Element | null): Element | null {
if (el == null || el.tagName === "BODY") return null;
@ -166,6 +175,7 @@ let transitionDuration = $computed(() =>
let contentClicking = false;
const focusedElement = document.activeElement;
function close(opts: { useSendAnimation?: boolean } = {}) {
if (opts.useSendAnimation) {
useSendAnime = true;
@ -175,10 +185,12 @@ function close(opts: { useSendAnimation?: boolean } = {}) {
if (props.src) props.src.style.pointerEvents = "auto";
showing = false;
emit("close");
focusedElement.focus();
}
function onBgClick() {
if (contentClicking) return;
focusedElement.focus();
emit("click");
}
@ -481,6 +493,7 @@ defineExpose({
}
.root {
outline: none;
&.dialog {
> .content {
position: fixed;

View file

@ -158,6 +158,7 @@ function onContextmenu(ev: MouseEvent) {
flex-direction: column;
contain: content;
border-radius: var(--radius);
margin: auto;
--root-margin: 24px;

View file

@ -3,59 +3,64 @@
ref="modal"
:prefer-type="'dialog'"
@click="onBgClick"
@keyup.esc="$emit('close')"
@closed="$emit('closed')"
>
<div
ref="rootEl"
class="ebkgoccj"
:style="{
width: `${width}px`,
height: scroll
? height
? `${height}px`
: null
: height
? `min(${height}px, 100%)`
: '100%',
}"
@keydown="onKeydown"
>
<div ref="headerEl" class="header">
<button
v-if="withOkButton"
class="_button"
@click="$emit('close')"
>
<i class="ph-x ph-bold ph-lg"></i>
</button>
<span class="title">
<slot name="header"></slot>
</span>
<button
v-if="!withOkButton"
class="_button"
@click="$emit('close')"
>
<i class="ph-x ph-bold ph-lg"></i>
</button>
<button
v-if="withOkButton"
class="_button"
:disabled="okButtonDisabled"
@click="$emit('ok')"
>
<i class="ph-check ph-bold ph-lg"></i>
</button>
<FocusTrap v-model:active="isActive">
<div
ref="rootEl"
class="ebkgoccj"
:style="{
width: `${width}px`,
height: scroll
? height
? `${height}px`
: null
: height
? `min(${height}px, 100%)`
: '100%',
}"
@keydown="onKeydown"
tabindex="-1"
>
<div ref="headerEl" class="header">
<button
v-if="withOkButton"
class="_button"
@click="$emit('close')"
>
<i class="ph-x ph-bold ph-lg"></i>
</button>
<span class="title">
<slot name="header"></slot>
</span>
<button
v-if="!withOkButton"
class="_button"
@click="$emit('close')"
>
<i class="ph-x ph-bold ph-lg"></i>
</button>
<button
v-if="withOkButton"
class="_button"
:disabled="okButtonDisabled"
@click="$emit('ok')"
>
<i class="ph-check ph-bold ph-lg"></i>
</button>
</div>
<div class="body">
<slot :width="bodyWidth" :height="bodyHeight"></slot>
</div>
</div>
<div class="body">
<slot :width="bodyWidth" :height="bodyHeight"></slot>
</div>
</div>
</FocusTrap>
</MkModal>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted } from "vue";
import { FocusTrap } from "focus-trap-vue";
import MkModal from "./MkModal.vue";
const props = withDefaults(

View file

@ -84,6 +84,7 @@
:detailedView="detailedView"
:parentId="appearNote.parentId"
@push="(e) => router.push(notePage(e))"
@focusfooter="footerEl.focus()"
></MkSubNoteContent>
<div v-if="translating || translation" class="translation">
<MkLoading v-if="translating" mini />
@ -117,7 +118,7 @@
<MkTime :time="appearNote.createdAt" mode="absolute" />
</MkA>
</div>
<footer ref="el" class="footer" @click.stop>
<footer ref="footerEl" class="footer" @click.stop tabindex="-1">
<XReactionsViewer
v-if="enableEmojiReactions"
ref="reactionsViewer"
@ -278,6 +279,7 @@ const isRenote =
note.poll == null;
const el = ref<HTMLElement>();
const footerEl = ref<HTMLElement>();
const menuButton = ref<HTMLElement>();
const starButton = ref<InstanceType<typeof XStarButton>>();
const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
@ -298,8 +300,8 @@ const keymap = {
r: () => reply(true),
"e|a|plus": () => react(true),
q: () => renoteButton.value.renote(true),
"up|k|shift+tab": focusBefore,
"down|j|tab": focusAfter,
"up|k": focusBefore,
"down|j": focusAfter,
esc: blur,
"m|o": () => menu(true),
s: () => showContent.value !== showContent.value,

View file

@ -1,6 +1,6 @@
<template>
<div v-size="{ min: [350, 500] }" class="fefdfafb">
<MkAvatar class="avatar" :user="$i" />
<MkAvatar class="avatar" :user="$i" disableLink />
<div class="main">
<div class="header">
<MkUserName :user="$i" />

View file

@ -26,6 +26,7 @@
:note="note"
:parentId="appearNote.parentId"
:conversation="conversation"
@focusfooter="footerEl.focus()"
/>
<div v-if="translating || translation" class="translation">
<MkLoading v-if="translating" mini />
@ -46,7 +47,7 @@
</div>
</div>
</div>
<footer class="footer" @click.stop>
<footer ref="footerEl" class="footer" @click.stop tabindex="-1">
<XReactionsViewer
v-if="enableEmojiReactions"
ref="reactionsViewer"
@ -212,6 +213,7 @@ const isRenote =
note.poll == null;
const el = ref<HTMLElement>();
const footerEl = ref<HTMLElement>();
const menuButton = ref<HTMLElement>();
const starButton = ref<InstanceType<typeof XStarButton>>();
const renoteButton = ref<InstanceType<typeof XRenoteButton>>();

View file

@ -7,6 +7,8 @@
:transparent-bg="true"
@click="modal.close()"
@closed="emit('closed')"
tabindex="-1"
v-focus
>
<MkMenu
:items="items"

View file

@ -55,7 +55,7 @@
:class="{ active: showPreview }"
@click="showPreview = !showPreview"
>
<i class="ph-file-code ph-bold ph-lg"></i>
<i class="ph-binoculars ph-bold ph-lg"></i>
</button>
<button
class="submit _buttonGradate"
@ -462,15 +462,21 @@ if (
props.reply &&
["home", "followers", "specified"].includes(props.reply.visibility)
) {
visibility = props.reply.visibility;
if (props.reply.visibility === "specified") {
os.api("users/show", {
userIds: props.reply.visibleUserIds.filter(
(uid) => uid !== $i.id && uid !== props.reply.userId
),
}).then((users) => {
users.forEach(pushVisibleUser);
});
if (props.reply.visibility === 'home' && visibility === 'followers') {
visibility = 'followers';
} else if (['home', 'followers'].includes(props.reply.visibility) && visibility === 'specified') {
visibility = 'specified';
} else {
visibility = props.reply.visibility;
}
if (visibility === 'specified') {
if (props.reply.visibleUserIds) {
os.api('users/show', {
userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId),
}).then(users => {
users.forEach(pushVisibleUser);
});
}
if (props.reply.userId !== $i.id) {
os.api("users/show", { userId: props.reply.userId }).then(

View file

@ -154,22 +154,22 @@ export default defineComponent({
? i18n.ts.unmarkAsSensitive
: i18n.ts.markAsSensitive,
icon: file.isSensitive
? "ph-eye-slash ph-bold ph-lg"
: "ph-eye ph-bold ph-lg",
? "ph-eye ph-bold ph-lg"
: "ph-eye-slash ph-bold ph-lg",
action: () => {
this.toggleSensitive(file);
},
},
{
text: i18n.ts.describeFile,
icon: "ph-cursor-text ph-bold ph-lg",
icon: "ph-subtitles ph-bold ph-lg",
action: () => {
this.describe(file);
},
},
{
text: i18n.ts.attachCancel,
icon: "ph-circle-wavy-warning ph-bold ph-lg",
icon: "ph-x ph-bold ph-lg",
action: () => {
this.detachMedia(file.id);
},
@ -198,7 +198,6 @@ export default defineComponent({
height: 64px;
margin-right: 4px;
border-radius: 4px;
overflow: hidden;
cursor: move;
&:hover > .remove {

View file

@ -1,68 +0,0 @@
<template>
<button
v-if="modelValue"
class="fade _button"
@click.stop="toggle"
>
<span>{{ i18n.ts.showMore }}</span>
</button>
<button
v-if="!modelValue"
class="showLess _button"
@click.stop="toggle"
>
<span>{{ i18n.ts.showLess }}</span>
</button>
</template>
<script lang="ts" setup>
import { i18n } from "@/i18n";
const props = defineProps<{
modelValue: boolean;
}>();
const emit = defineEmits<{
(ev: "update:modelValue", v: boolean): void;
}>();
const toggle = () => {
emit("update:modelValue", !props.modelValue);
};
</script>
<style lang="scss" scoped>
.fade {
display: block;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
> span {
display: inline-block;
background: var(--panel);
padding: 0.4em 1em;
font-size: 0.8em;
border-radius: 999px;
box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
}
&:hover {
> span {
background: var(--panelHighlight);
}
}
}
.showLess {
width: 100%;
margin-top: 1em;
position: sticky;
bottom: var(--stickyBottom);
> span {
display: inline-block;
background: var(--panel);
padding: 6px 10px;
font-size: 0.8em;
border-radius: 999px;
box-shadow: 0 0 7px 7px var(--bg);
}
}
</style>

View file

@ -35,7 +35,20 @@
class="content"
:class="{ collapsed, isLong, showContent: note.cw && !showContent }"
>
<div class="body">
<XCwButton
ref="cwButton"
v-if="note.cw && !showContent"
v-model="showContent"
:note="note"
v-on:keydown="focusFooter"
/>
<div
class="body"
v-bind="{
'aria-label': !showContent ? '' : null,
tabindex: !showContent ? '-1' : null,
}"
>
<span v-if="note.deletedAt" style="opacity: 0.5"
>({{ i18n.ts.deleted }})</span
>
@ -96,22 +109,33 @@
<XNoteSimple :note="note.renote" />
</div>
</template>
<div
v-if="note.cw && !showContent"
tabindex="0"
v-on:focus="cwButton?.focus()"
></div>
</div>
<XShowMoreButton v-if="isLong" v-model="collapsed"></XShowMoreButton>
<XCwButton v-if="note.cw" v-model="showContent" :note="note" />
<XShowMoreButton
v-if="isLong"
v-model="collapsed"
></XShowMoreButton>
<XCwButton
v-if="note.cw && showContent"
v-model="showContent"
:note="note"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import {} from "vue";
import { ref } from "vue";
import * as misskey from "calckey-js";
import * as mfm from "mfm-js";
import XNoteSimple from "@/components/MkNoteSimple.vue";
import XMediaList from "@/components/MkMediaList.vue";
import XPoll from "@/components/MkPoll.vue";
import MkUrlPreview from "@/components/MkUrlPreview.vue";
import XShowMoreButton from "./MkShowMoreButton.vue";
import XCwButton from "@/components/MkCwButton.vue";
import { extractUrlFromMfm } from "@/scripts/extract-url-from-mfm";
import { i18n } from "@/i18n";
@ -126,20 +150,27 @@ const props = defineProps<{
const emit = defineEmits<{
(ev: "push", v): void;
(ev: "focusfooter"): void;
}>();
const cwButton = ref<HTMLElement>();
const isLong =
!props.detailedView &&
props.note.cw == null &&
props.note.text != null &&
(props.note.text.split("\n").length > 9 || props.note.text.length > 500);
const collapsed = $ref(props.note.cw == null && isLong);
const urls = props.note.text
? extractUrlFromMfm(mfm.parse(props.note.text)).slice(0, 5)
: null;
let showContent = $ref(false);
function focusFooter(ev) {
if (ev.key == "Tab" && !ev.getModifierState("Shift")) {
emit("focusfooter");
}
}
</script>
<style lang="scss" scoped>
@ -231,6 +262,9 @@ let showContent = $ref(false);
margin-top: -50px;
padding-top: 50px;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
}
&.collapsed > .body {
box-sizing: border-box;
@ -253,6 +287,43 @@ let showContent = $ref(false);
top: 40px;
}
}
:deep(.fade) {
display: block;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
> span {
display: inline-block;
background: var(--panel);
padding: 0.4em 1em;
font-size: 0.8em;
border-radius: 999px;
box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
}
&:hover {
> span {
background: var(--panelHighlight);
}
}
}
}
:deep(.showLess) {
width: 100%;
margin-top: 1em;
position: sticky;
bottom: var(--stickyBottom);
> span {
display: inline-block;
background: var(--panel);
padding: 6px 10px;
font-size: 0.8em;
border-radius: 999px;
box-shadow: 0 0 7px 7px var(--bg);
}
}
}
}

View file

@ -9,7 +9,6 @@
v-if="item.type === 'a'"
:href="item.href"
:target="item.target"
:tabindex="i"
class="_button item"
:class="{ danger: item.danger, active: item.active }"
>
@ -22,7 +21,6 @@
</a>
<button
v-else-if="item.type === 'button'"
:tabindex="i"
class="_button item"
:class="{ danger: item.danger, active: item.active }"
:disabled="item.active"
@ -38,7 +36,6 @@
<MkA
v-else
:to="item.to"
:tabindex="i"
class="_button item"
:class="{ danger: item.danger, active: item.active }"
>
@ -99,7 +96,8 @@ export default defineComponent({
font-size: 0.9em;
margin-bottom: 0.3rem;
&:hover {
&:hover,
&:focus-visible {
text-decoration: none;
background: var(--panelHighlight);
}

View file

@ -46,7 +46,10 @@
/></MkA>
<p class="username"><MkAcct :user="user" /></p>
</div>
<div class="description" :class="{ collapsed: isLong && collapsed }">
<div
class="description"
:class="{ collapsed: isLong && collapsed }"
>
<Mfm
v-if="user.description"
:text="user.description"
@ -55,7 +58,20 @@
:custom-emojis="user.emojis"
/>
</div>
<XShowMoreButton v-if="isLong" v-model="collapsed"></XShowMoreButton>
<button
v-if="isLong && collapsed"
class="fade _button"
@click.stop="collapsed = false"
>
<span>{{ i18n.ts.showMore }}</span>
</button>
<button
v-if="isLong && !collapsed"
class="showLess _button"
@click.stop="collapsed = true"
>
<span>{{ i18n.ts.showLess }}</span>
</button>
<div v-if="user.fields.length > 0" class="fields">
<dl
v-for="(field, i) in user.fields"
@ -115,7 +131,6 @@ import * as Acct from "calckey-js/built/acct";
import type * as misskey from "calckey-js";
import MkFollowButton from "@/components/MkFollowButton.vue";
import { userPage } from "@/filters/user";
import XShowMoreButton from "./MkShowMoreButton.vue";
import * as os from "@/os";
import { $i } from "@/account";
import { i18n } from "@/i18n";
@ -137,14 +152,15 @@ let user = $ref<misskey.entities.UserDetailed | null>(null);
let top = $ref(0);
let left = $ref(0);
let isLong = $ref(false);
let collapsed = $ref(!isLong);
onMounted(() => {
if (typeof props.q === "object") {
user = props.q;
isLong = (user.description.split("\n").length > 9 || user.description.length > 400);
isLong =
user.description.split("\n").length > 9 ||
user.description.length > 400;
} else {
const query = props.q.startsWith("@")
? Acct.parse(props.q.substr(1))
@ -153,10 +169,11 @@ onMounted(() => {
os.api("users/show", query).then((res) => {
if (!props.showing) return;
user = res;
isLong = (user.description.split("\n").length > 9 || user.description.length > 400);
isLong =
user.description.split("\n").length > 9 ||
user.description.length > 400;
});
}
const rect = props.source.getBoundingClientRect();
const x =
@ -301,7 +318,7 @@ onMounted(() => {
> .fields {
padding: 0 16px;
font-size: .8em;
font-size: 0.8em;
margin-top: 1em;
> .field {

View file

@ -46,6 +46,7 @@
:user="user"
class="avatar"
:show-indicator="true"
disableLink
/>
<div class="body">
<MkUserName :user="user" class="name" />
@ -73,6 +74,7 @@
:user="user"
class="avatar"
:show-indicator="true"
disableLink
/>
<div class="body">
<MkUserName :user="user" class="name" />

View file

@ -7,7 +7,7 @@
>
<div class="beaffaef">
<div v-for="u in users" :key="u.id" class="user">
<MkAvatar class="avatar" :user="u" />
<MkAvatar class="avatar" :user="u" disableLink />
<MkUserName class="name" :user="u" :nowrap="true" />
</div>
<div v-if="users.length < count" class="omitted">

View file

@ -1,7 +1,7 @@
<template>
<div class="vjoppmmu">
<template v-if="edit">
<header>
<header tabindex="-1" v-focus>
<MkSelect
v-model="widgetAdderSelected"
style="margin-bottom: var(--margin)"

View file

@ -1,6 +1,6 @@
<template>
<div class="dwzlatin" :class="{ opened }">
<div class="header _button" @click="toggle">
<button class="header _button" @click="toggle">
<span class="icon"><slot name="icon"></slot></span>
<span class="text"><slot name="label"></slot></span>
<span class="right">
@ -8,7 +8,7 @@
<i v-if="opened" class="ph-caret-up ph-bold ph-lg icon"></i>
<i v-else class="ph-caret-down ph-bold ph-lg icon"></i>
</span>
</div>
</button>
<KeepAlive>
<div v-if="openedAtLeastOnce" v-show="opened" class="body">
<MkSpacer :margin-min="14" :margin-max="22">

View file

@ -66,6 +66,9 @@ function toggle(): void {
&:hover {
border-color: var(--inputBorderHover) !important;
}
&:focus-within {
outline: auto;
}
&.checked {
background-color: var(--accentedBg) !important;

View file

@ -99,6 +99,9 @@ const toggle = () => {
border-color: var(--inputBorderHover) !important;
}
}
&:focus-within > .button {
outline: auto;
}
> .label {
margin-left: 12px;

View file

@ -19,6 +19,7 @@
class="avatar"
:user="$i"
:disable-preview="true"
disableLink
/>
</div>
<template v-if="metadata">
@ -33,6 +34,7 @@
:user="metadata.avatar"
:disable-preview="true"
:show-indicator="true"
disableLink
/>
<i
v-else-if="metadata.icon && !narrow"

View file

@ -5,6 +5,9 @@
:is="currentPageComponent"
:key="key"
v-bind="Object.fromEntries(currentPageProps)"
tabindex="-1"
v-focus
style="outline: none"
/>
<template #fallback>

View file

@ -0,0 +1,3 @@
export default {
mounted: (el) => el.focus()
}

View file

@ -11,6 +11,7 @@ import anim from "./anim";
import clickAnime from "./click-anime";
import panel from "./panel";
import adaptiveBorder from "./adaptive-border";
import focus from "./focus";
export default function (app: App) {
app.directive("userPreview", userPreview);
@ -25,4 +26,5 @@ export default function (app: App) {
app.directive("click-anime", clickAnime);
app.directive("panel", panel);
app.directive("adaptive-border", adaptiveBorder);
app.directive("focus", focus);
}

View file

@ -76,23 +76,32 @@ export default {
ev.preventDefault();
});
function showTooltip() {
window.clearTimeout(self.showTimer);
window.clearTimeout(self.hideTimer);
self.showTimer = window.setTimeout(self.show, delay);
}
function hideTooltip() {
window.clearTimeout(self.showTimer);
window.clearTimeout(self.hideTimer);
self.hideTimer = window.setTimeout(self.close, delay);
}
el.addEventListener(
start,
() => {
window.clearTimeout(self.showTimer);
window.clearTimeout(self.hideTimer);
self.showTimer = window.setTimeout(self.show, delay);
},
start, showTooltip,
{ passive: true },
);
el.addEventListener(
"focusin", showTooltip,
{ passive: true },
);
el.addEventListener(
end,
() => {
window.clearTimeout(self.showTimer);
window.clearTimeout(self.hideTimer);
self.hideTimer = window.setTimeout(self.close, delay);
},
end, hideTooltip,
{ passive: true },
);
el.addEventListener(
"focusout", hideTooltip,
{ passive: true },
);

View file

@ -18,6 +18,7 @@
<option value="publishing">{{ i18n.ts.publishing }}</option>
<option value="suspended">{{ i18n.ts.suspended }}</option>
<option value="blocked">{{ i18n.ts.blocked }}</option>
<option value="silenced">{{ i18n.ts.silenced }}</option>
<option value="notResponding">
{{ i18n.ts.notResponding }}
</option>
@ -105,13 +106,11 @@
<script lang="ts" setup>
import { computed } from "vue";
import MkButton from "@/components/MkButton.vue";
import MkInput from "@/components/form/input.vue";
import MkSelect from "@/components/form/select.vue";
import MkPagination from "@/components/MkPagination.vue";
import MkInstanceCardMini from "@/components/MkInstanceCardMini.vue";
import FormSplit from "@/components/form/split.vue";
import * as os from "@/os";
import { i18n } from "@/i18n";
let host = $ref("");
@ -134,6 +133,8 @@ const pagination = {
? { suspended: true }
: state === "blocked"
? { blocked: true }
: state === "silenced"
? { silenced: true }
: state === "notResponding"
? { notResponding: true }
: {}),
@ -143,6 +144,7 @@ const pagination = {
function getStatus(instance) {
if (instance.isSuspended) return "Suspended";
if (instance.isBlocked) return "Blocked";
if (instance.isSilenced) return "Silenced";
if (instance.isNotResponding) return "Error";
return "Alive";
}

View file

@ -313,10 +313,8 @@ onUnmounted(() => {
font-weight: normal;
opacity: 0.7;
&:hover {
opacity: 1;
}
&:hover,
&:focus-visible,
&.active {
opacity: 1;
}

View file

@ -3,7 +3,6 @@
<MkStickyContainer>
<template #header
><MkPageHeader
v-model:tab="tab"
:actions="headerActions"
:tabs="headerTabs"
:display-back-button="true"

View file

@ -7,13 +7,31 @@
:display-back-button="true"
/></template>
<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
<MkTab v-model="tab" class="_formBlock">
<option value="block">{{ i18n.ts.blockedInstances }}</option>
<option value="silence">{{ i18n.ts.silencedInstances }}</option>
</MkTab>
<FormSuspense :p="init">
<FormTextarea v-model="blockedHosts" class="_formBlock">
<FormTextarea
v-if="tab === 'block'"
v-model="blockedHosts"
class="_formBlock"
>
<span>{{ i18n.ts.blockedInstances }}</span>
<template #caption>{{
i18n.ts.blockedInstancesDescription
}}</template>
</FormTextarea>
<FormTextarea
v-else-if="tab === 'silence'"
v-model="silencedHosts"
class="_formBlock"
>
<span>{{ i18n.ts.silencedInstances }}</span>
<template #caption>{{
i18n.ts.silencedInstancesDescription
}}</template>
</FormTextarea>
<FormButton primary class="_formBlock" @click="save"
><i class="ph-floppy-disk-back ph-bold ph-lg"></i>
@ -29,21 +47,28 @@ import {} from "vue";
import FormButton from "@/components/MkButton.vue";
import FormTextarea from "@/components/form/textarea.vue";
import FormSuspense from "@/components/form/suspense.vue";
import MkTab from "@/components/MkTab.vue";
import * as os from "@/os";
import { fetchInstance } from "@/instance";
import { i18n } from "@/i18n";
import { definePageMetadata } from "@/scripts/page-metadata";
let blockedHosts: string = $ref("");
let silencedHosts: string = $ref("");
let tab = $ref("block");
async function init() {
const meta = await os.api("admin/meta");
blockedHosts = meta.blockedHosts.join("\n");
if (meta) {
blockedHosts = meta.blockedHosts.join("\n");
silencedHosts = meta.silencedHosts.join("\n");
}
}
function save() {
os.apiWithDialog("admin/update-meta", {
blockedHosts: blockedHosts.split("\n").map((h) => h.trim()) || [],
silencedHosts: silencedHosts.split("\n").map((h) => h.trim()) || [],
}).then(() => {
fetchInstance();
});

View file

@ -12,7 +12,12 @@
class="user"
:to="`/user-info/${user.id}`"
>
<MkAvatar :user="user" class="avatar" indicator />
<MkAvatar
:user="user"
class="avatar"
indicator
disableLink
/>
</MkA>
</div>
</Transition>

View file

@ -371,6 +371,34 @@
<template #label>Pro account</template>
</FormSwitch>
</FormSection>
<FormSection>
<template #label>Libre Translate</template>
<FormInput
v-model="libreTranslateApiUrl"
class="_formBlock"
>
<template #prefix
><i class="ph-link ph-bold ph-lg"></i
></template>
<template #label
>Libre Translate API URL</template
>
</FormInput>
<FormInput
v-model="libreTranslateApiKey"
class="_formBlock"
>
<template #prefix
><i class="ph-key ph-bold ph-lg"></i
></template>
<template #label
>Libre Translate API Key</template
>
</FormInput>
</FormSection>
</div>
</FormSuspense>
</MkSpacer>
@ -422,6 +450,8 @@ let swPublicKey: any = $ref(null);
let swPrivateKey: any = $ref(null);
let deeplAuthKey: string = $ref("");
let deeplIsPro: boolean = $ref(false);
let libreTranslateApiUrl: string = $ref("");
let libreTranslateApiKey: string = $ref("");
let defaultReaction: string = $ref("");
let defaultReactionCustom: string = $ref("");
@ -456,6 +486,8 @@ async function init() {
swPrivateKey = meta.swPrivateKey;
deeplAuthKey = meta.deeplAuthKey;
deeplIsPro = meta.deeplIsPro;
libreTranslateApiUrl = meta.libreTranslateApiUrl;
libreTranslateApiKey = meta.libreTranslateApiKey;
defaultReaction = ["⭐", "👍", "❤️"].includes(meta.defaultReaction)
? meta.defaultReaction
: "custom";
@ -498,6 +530,8 @@ function save() {
swPrivateKey,
deeplAuthKey,
deeplIsPro,
libreTranslateApiUrl,
libreTranslateApiKey,
defaultReaction,
}).then(() => {
fetchInstance();

View file

@ -23,6 +23,7 @@
class="avatar"
:user="req.follower"
:show-indicator="true"
disableLink
/>
<div class="body">
<div class="name">

View file

@ -98,6 +98,14 @@
@update:modelValue="toggleBlock"
>{{ i18n.ts.blockThisInstance }}</FormSwitch
>
<FormSwitch
v-model="isSilenced"
class="_formBlock"
@update:modelValue="toggleSilence"
>{{
i18n.ts.silenceThisInstance
}}</FormSwitch
>
</FormSuspense>
<MkButton @click="refreshMetadata"
><i
@ -329,7 +337,7 @@
import { watch } from "vue";
import { Virtual } from "swiper";
import { Swiper, SwiperSlide } from "swiper/vue";
import type * as misskey from "calckey-js";
import type * as calckey from "calckey-js";
import MkChart from "@/components/MkChart.vue";
import MkObjectView from "@/components/MkObjectView.vue";
import FormLink from "@/components/form/link.vue";
@ -352,11 +360,13 @@ import "swiper/scss";
import "swiper/scss/virtual";
import { getProxiedImageUrlNullable } from "@/scripts/media-proxy";
type AugmentedInstanceMetadata = misskey.entities.DetailedInstanceMetadata & {
type AugmentedInstanceMetadata = calckey.entities.DetailedInstanceMetadata & {
blockedHosts: string[];
silencedHosts: string[];
};
type AugmentedInstance = misskey.entities.Instance & {
type AugmentedInstance = calckey.entities.Instance & {
isBlocked: boolean;
isSilenced: boolean;
};
const props = defineProps<{
@ -373,6 +383,7 @@ let meta = $ref<AugmentedInstanceMetadata | null>(null);
let instance = $ref<AugmentedInstance | null>(null);
let suspended = $ref(false);
let isBlocked = $ref(false);
let isSilenced = $ref(false);
let faviconUrl = $ref(null);
const usersPagination = {
@ -386,16 +397,14 @@ const usersPagination = {
offsetMode: true,
};
async function init() {
meta = await os.api("admin/meta");
}
async function fetch() {
meta = (await os.api("admin/meta")) as AugmentedInstanceMetadata;
instance = (await os.api("federation/show-instance", {
host: props.host,
})) as AugmentedInstance;
suspended = instance.isSuspended;
isBlocked = instance.isBlocked;
isSilenced = instance.isSilenced;
faviconUrl =
getProxiedImageUrlNullable(instance.faviconUrl, "preview") ??
getProxiedImageUrlNullable(instance.iconUrl, "preview");
@ -417,6 +426,22 @@ async function toggleBlock() {
});
}
async function toggleSilence() {
if (meta == null) return;
if (!instance) {
throw new Error(`Instance info not loaded`);
}
let silencedHosts: string[];
if (isSilenced) {
silencedHosts = meta.silencedHosts.concat([instance.host]);
} else {
silencedHosts = meta.silencedHosts.filter((x) => x !== instance!.host);
}
await os.api("admin/update-meta", {
silencedHosts,
});
}
async function toggleSuspend(v) {
await os.api("admin/federation/update-instance", {
host: instance.host,

View file

@ -2,7 +2,7 @@
<MkStickyContainer>
<template #header><MkPageHeader /></template>
<MkSpacer :content-max="800">
<div class="mwysmxbg">
<div :class="$style.root">
<div>{{ i18n.ts._mfm.intro }}</div>
<br />
<div class="section _block">
@ -137,6 +137,18 @@
</div>
</div>
</div>
<div class="section _block">
<div class="title">{{ i18n.ts._mfm.blockMath }}</div>
<div class="content">
<p>{{ i18n.ts._mfm.blockMathDescription }}</p>
<div class="preview">
<Mfm :text="preview_blockMath" />
<MkTextarea v-model="preview_blockMath"
><template #label>MFM</template></MkTextarea
>
</div>
</div>
</div>
<!-- deprecated
<div class="section _block">
<div class="title">{{ i18n.ts._mfm.search }}</div>
@ -341,6 +353,54 @@
</div>
</div>
</div>
<div class="section _block">
<div class="title">{{ i18n.ts._mfm.position }}</div>
<div class="content">
<p>{{ i18n.ts._mfm.positionDescription }}</p>
<div class="preview">
<Mfm :text="preview_position" />
<MkTextarea v-model="preview_position"
><span>MFM</span></MkTextarea
>
</div>
</div>
</div>
<div class="section _block">
<div class="title">{{ i18n.ts._mfm.scale }}</div>
<div class="content">
<p>{{ i18n.ts._mfm.scaleDescription }}</p>
<div class="preview">
<Mfm :text="preview_scale" />
<MkTextarea v-model="preview_scale"
><span>MFM</span></MkTextarea
>
</div>
</div>
</div>
<div class="section _block">
<div class="title">{{ i18n.ts._mfm.foreground }}</div>
<div class="content">
<p>{{ i18n.ts._mfm.foregroundDescription }}</p>
<div class="preview">
<Mfm :text="preview_fg" />
<MkTextarea v-model="preview_fg"
><span>MFM</span></MkTextarea
>
</div>
</div>
</div>
<div class="section _block">
<div class="title">{{ i18n.ts._mfm.background }}</div>
<div class="content">
<p>{{ i18n.ts._mfm.backgroundDescription }}</p>
<div class="preview">
<Mfm :text="preview_bg" />
<MkTextarea v-model="preview_bg"
><span>MFM</span></MkTextarea
>
</div>
</div>
</div>
<div class="section _block">
<div class="title">{{ i18n.ts._mfm.plain }}</div>
<div class="content">
@ -379,8 +439,11 @@ let preview_blockCode = $ref(
'```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```'
);
let preview_inlineMath = $ref("\\(x= \\frac{-b' \\pm \\sqrt{(b')^2-ac}}{a}\\)");
let preview_blockMath = $ref("\\[x= \\frac{-b' \\pm \\sqrt{(b')^2-ac}}{a}\\]");
let preview_quote = $ref(`> ${i18n.ts._mfm.dummy}`);
let preview_search = $ref(`${i18n.ts._mfm.dummy} 検索`);
let preview_search = $ref(
`${i18n.ts._mfm.dummy} [search]\n${i18n.ts._mfm.dummy} [検索]\n${i18n.ts._mfm.dummy} 検索`
);
let preview_jelly = $ref("$[jelly 🍮] $[jelly.speed=5s 🍮]");
let preview_tada = $ref("$[tada 🍮] $[tada.speed=5s 🍮]");
let preview_jump = $ref("$[jump 🍮] $[jump.speed=5s 🍮]");
@ -402,7 +465,17 @@ let preview_x4 = $ref("$[x4 🍮]");
let preview_blur = $ref(`$[blur ${i18n.ts._mfm.dummy}]`);
let preview_rainbow = $ref("$[rainbow 🍮] $[rainbow.speed=5s 🍮]");
let preview_sparkle = $ref("$[sparkle 🍮]");
let preview_rotate = $ref("$[rotate 🍮]");
let preview_rotate = $ref(
"$[rotate 🍮]\n$[rotate.deg=45 🍮]\n$[rotate.x,deg=45 Hello, world!]"
);
let preview_position = $ref(
"$[position.y=-1 Positioning]\n$[position.x=-1 Positioning]"
);
let preview_scale = $ref(
"$[scale.x=1.3 Scaling]\n$[scale.x=1.3,y=2 Scaling]\n$[scale.y=0.3 Tiny scaling]"
);
let preview_fg = $ref("$[fg.color=ff0000 Text color]");
let preview_bg = $ref("$[bg.color=ff0000 Background color]");
let preview_plain = $ref(
"<plain>**bold** @mention #hashtag `code` $[x2 🍮]</plain>"
);
@ -413,8 +486,8 @@ definePageMetadata({
});
</script>
<style lang="scss" scoped>
.mwysmxbg {
<style lang="scss" module>
.root {
background: var(--bg);
> .section {

View file

@ -6,14 +6,14 @@
{{ i18n.ts.addAccount }}</FormButton
>
<div
<button
v-for="account in accounts"
:key="account.id"
class="_panel _button lcjjdxlm"
@click="menu(account, $event)"
>
<div class="avatar">
<MkAvatar :user="account" class="avatar" />
<MkAvatar :user="account" class="avatar" disableLink />
</div>
<div class="body">
<div class="name">
@ -23,7 +23,7 @@
<MkAcct :user="account" />
</div>
</div>
</div>
</button>
</FormSuspense>
</div>
</template>
@ -158,6 +158,8 @@ definePageMetadata({
.lcjjdxlm {
display: flex;
padding: 16px;
width: 100%;
text-align: unset;
> .avatar {
display: block;

View file

@ -308,6 +308,7 @@ function showMenu(ev) {
height: 32px;
border-radius: 8px;
font-size: 18px;
z-index: 2;
}
> .fg {

View file

@ -204,10 +204,6 @@ hr {
pointer-events: none;
}
&:focus-visible {
outline: none;
}
&:disabled {
opacity: 0.5;
cursor: default;

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