Compare commits

...

46 Commits
main ... 0x7f

Author SHA1 Message Date
Michcio ebc1b09298 Tear out the easily breaking early boot error page 2022-10-17 16:05:55 +01:00
Michcio 52cbda91b9 Start dev faster if possible by not cleaning before 2022-10-17 16:05:55 +01:00
Michcio ef77f281a6 Change icons for visibilities and make public visibility icon show up in
feed too
2022-10-17 16:05:54 +01:00
Chloe Kudryavtsev 010a65dd92 client: replace blinking indicator with icon colouring
It was annoying, and turns out, also a CPU hog!

Changelog: Removed
2022-10-17 16:05:50 +01:00
Michcio ed515cc24c Send notifications also when the tab is not visible 2022-10-16 15:08:25 +01:00
Michcio e44be45c90 Produce sourcemaps always (debugging on prod is hard) 2022-10-16 15:08:25 +01:00
Michcio 8fcfdc88d8 Replace toast (sorry!) notifications with browser ones
Icons don't work in Safari just because they don't lmao

This is just barely tested, bear with me
2022-10-16 15:08:25 +01:00
Michcio a903263e00 Apparently unbundling mfm-js is bad for yarn dev 2022-10-16 15:08:25 +01:00
Michcio ac1072fd52 Split bundles harder 2022-10-16 15:08:25 +01:00
Michcio ba331f771d client: Replace CRC32 with FNV1a in colour hashing
Paste fnv-plus directly to save bundle size
2022-10-16 15:08:25 +01:00
Michcio 24a10acd34 client: Add colour coding to make people distinguishable despite
changing pfps
2022-10-16 15:08:25 +01:00
Michcio 98a15fd080 Compact mentions to just heads inside notifications 2022-10-16 15:08:25 +01:00
Michcio 7e635d23d3 Preview existing replies in tooltip when hovering reply button 2022-10-16 15:08:25 +01:00
Michcio 427e36e7ec Preview replied-to note in tooltip when hovering the arrow 2022-10-16 15:08:24 +01:00
Michcio fd477f0ad2 Exclude CW'd posts from supercompact collapsing 2022-10-16 15:08:24 +01:00
Michcio 1258a588c7 Collapse inline replied-to post to make it more compact 2022-10-16 15:08:24 +01:00
Michcio b541ecb099 Shorten notification text by capping it at 75 chars 2022-10-16 15:08:24 +01:00
Michał Sidor 8840724a7c Show reacting people next to reaction buttons
This change replaces the reaction count on the reaction buttons under
the post with micro avatars of the people reacting. This makes the
whole thing feel more personal IMHO.

Performance concerns: because the posts by themselves only contain
reaction counts, this means executing an extra API call is done to
fetch the list of users who reacted. This was already being done when
hovering a reaction button, and my Raspberry Pi is doing pretty fine
despite this patch.

Further development was done to lazify the API call, so now
reaction avatars are now fetched only when the reaction bar
slides into view. This should lower the load a bit.

Borrowed some ideas from code at https://medium.com/js-dojo/lazy-rendering-in-vue-to-improve-performance-dcccd445d5f

TODO: check there might be a glitch when adding a reaction
because it is already in view
2022-10-16 15:08:24 +01:00
Michał Sidor 191b2692d2 Modify social timeline to exclude convos with only 1 person I like
This is an attempt at introducing filtering of replies in timeline in the
style of Mastodon or Pleroma's "only replies directed at me or someone I follow".

Currently one way this surely fails is that self-replies by someone I follow in
a conversation solely with someone I don't follow will pass this filter, and
I will see a conversation I don't want to see.

This probably needs more testing to verify that it's doing what's expected of it.
2022-10-16 15:08:24 +01:00
Michał Sidor b74c924f66 My instance-specific assets and client defaults
I change the favicons and change some of the device-stored
client settings so that I don't have to set them on every
device every time.
2022-10-16 15:08:24 +01:00
Michcio fb42e40958 Add Cherry Bleu theme variant
This is my modification of the dark cherry theme, caused by my
annoyance that you can set a wallpaper in the client, but it's
invisible almost all the time.

What I tried to do here is make a lot more things transparent,
so that the wallpaper would be visible more. It also looks nasty
in some situations, but this is an acceptable tradeoff for me
personally.
2022-10-16 15:08:24 +01:00
Michcio 49d861c6e6 Trim the browser targets list in the transpilation config
I only use Safari (and sometimes Firefox, but not much), and I do not
particularly care for visitors to my instance, so this is again an attempt
at allowing the compiler to be more reckless wrt polyfilling to minimize
generated code.
2022-10-16 15:08:24 +01:00
Michcio 93d81bd695 chore: Provide type for toggleReaction 2022-10-16 15:08:24 +01:00
Michcio f1775debb5 backend: Provide type for signedGet 2022-10-16 15:08:24 +01:00
Michcio 58002cac58 Trash integrations lmao 2022-10-16 15:08:23 +01:00
Michcio 6aa52ecbfa Remove Cypress from dependencies
I don't run e2e tests, and my instance is a Raspberry Pi, and so installing
Cypress is an extra gigabyte of disk space wasted for me.
2022-10-16 15:08:23 +01:00
Michcio 045c9bf088 Remove Deck UI
I'm not sure this even makes sense, I was just trying to remove more code
from the client.
2022-10-16 15:08:23 +01:00
Michcio f53ff2089b Remove all right click context menu functionality
The context menus provided by Misskey, overriding the browser context menus
on right click, were driving me very angry. This makes it much easier to copy
image URLs or even just do a quick "Inspect element".

Side victims: the reaction picker context menu feature. I never used it, so
I am only guessing what it was doing, but since I removed the whole underlying
mechanic, it only felt right to yeet the feature too.
2022-10-16 15:08:23 +01:00
Michcio 92bfbc0bb6 Mock types for redis-lock 2022-10-16 15:08:23 +01:00
Michcio 5bb2d0f284 Retouch types in server index 2022-10-16 15:08:23 +01:00
Michcio 7205021d62 Fix type errors in withPackedNote 2022-10-16 15:08:23 +01:00
Michcio b1d133c3d5 Deal with withPackedNote(onNote) types in stream channels 2022-10-16 15:08:23 +01:00
Michcio 56f577ddb8 Fix type import in stream emitter typing 2022-10-16 15:08:23 +01:00
Michcio 12dd7deac5 Remove authentication type edge case in streaming init 2022-10-16 15:08:23 +01:00
Michcio f0b0e46e45 Reassure typechecker about token in authenticate 2022-10-16 15:08:22 +01:00
Michcio 71f339b23a Broaden type in authenticate as undefined is also nullable 2022-10-16 15:08:22 +01:00
Michcio 0120dee999 Upgrade bull-board to unify misaligned types in its packages 2022-10-16 15:08:22 +01:00
Michcio 9f3b85527d Fix types in summaly connector 2022-10-16 15:08:22 +01:00
Michcio 79c71bf22a Fix type errors in logger service 2022-10-16 15:08:22 +01:00
Michcio 957a69779a Fix typos in syslog initialization 2022-10-16 15:08:22 +01:00
Michcio 80a2bc401a Remove some uses of `as any[]` 2022-10-16 15:08:22 +01:00
Michcio f17eb379ac Provide intermediate type to calm typechecker down in i18n 2022-10-16 15:08:22 +01:00
Michcio 4568fba7a9 Mock typings for twemoji-parser's regex file 2022-10-16 15:08:22 +01:00
Michcio 40998587b3 Add packages for purportedly missing types 2022-10-16 15:08:22 +01:00
Michcio d1924e875a build: Force resolution of types/node 2022-10-16 15:08:21 +01:00
Michcio 655f7a8dfc Narrow type of isPureRenote 2022-10-16 15:08:21 +01:00
138 changed files with 709 additions and 4345 deletions

View File

@ -324,9 +324,6 @@ dayX: "{day}"
monthX: "{month}"
yearX: "{year}"
pages: "الصفحات"
integration: "التكامل"
connectService: "اتصل"
disconnectService: "اقطع الاتصال"
enableLocalTimeline: "تفعيل الخيط المحلي"
enableGlobalTimeline: "تفعيل الخيط الزمني الشامل"
disablingTimelinesInfo: "سيتمكن المديرون والمشرفون من الوصول إلى كل الخيوط الزمنية\
@ -711,7 +708,6 @@ apply: "تطبيق"
receiveAnnouncementFromInstance: "استلم إشعارات من هذا المثيل"
emailNotification: "إشعارات البريد الكتروني"
inChannelSearch: "ابحث عن قناة"
useReactionPickerForContextMenu: "افتح منتقي التفاعلات عند النقر بالزر الأيمن"
typingUsers: "{users} يكتب(ون)..."
jumpToSpecifiedDate: "انتقل إلى تاريخ محدد"
showingPastTimeline: "أنت تستعرض حاليًا خيطًا زمنيًا قديمًا"

View File

@ -339,9 +339,6 @@ dayX: "{day}"
monthX: "{month}"
yearX: "{year}"
pages: "পৃষ্ঠা"
integration: "ইন্টিগ্রেশন"
connectService: "সংযুক্ত করুন"
disconnectService: "সংযোগ বিচ্ছিন্ন করুন"
enableLocalTimeline: "স্থানীয় টাইমলাইন চালু করুন"
enableGlobalTimeline: "গ্লোবাল টাইমলাইন চালু করুন"
disablingTimelinesInfo: "আপনি এই টাইমলাইনগুলি বন্ধ করলেও প্রশাসক এবং মডারেটররা এই\
@ -773,7 +770,6 @@ receiveAnnouncementFromInstance: "এই ইন্সট্যান্স থ
emailNotification: "ইমেইল বিজ্ঞপ্তি"
publish: "প্রকাশ"
inChannelSearch: "চ্যানেলে খুঁজুন"
useReactionPickerForContextMenu: "রাইট ক্লিকের মাধ্যমে রিঅ্যাকশন পিকার খুলুন"
typingUsers: "{users} লেখছে"
jumpToSpecifiedDate: "একটি নির্দিষ্ট তারিখে যান"
showingPastTimeline: "অতীতের টাইমলাইন দেখানো হচ্ছে"

View File

@ -307,9 +307,6 @@ dayX: "{day}"
monthX: "{month}"
yearX: "{year}"
pages: "Stránky"
integration: "Integrace"
connectService: "Připojit"
disconnectService: "Odpojit"
enableLocalTimeline: "Povolit lokální čas"
enableGlobalTimeline: "Povolit globální čas"
registration: "Registrace"

View File

@ -350,9 +350,6 @@ dayX: "{day}"
monthX: "{month}"
yearX: "{year}"
pages: "Seiten"
integration: "Integration"
connectService: "Verbinden"
disconnectService: "Trennen"
enableLocalTimeline: "Lokale Chronik aktivieren"
enableGlobalTimeline: "Globale Chronik aktivieren"
disablingTimelinesInfo: "Administratoren und Moderatoren haben immer Zugriff auf alle\
@ -793,7 +790,6 @@ receiveAnnouncementFromInstance: "Benachrichtigungen von dieser Instanz empfange
emailNotification: "Email-Benachrichtigungen"
publish: "Veröffentlichen"
inChannelSearch: "In Kanal suchen"
useReactionPickerForContextMenu: "Reaktionsauswahl durch Rechtsklick öffnen"
typingUsers: "{users} ist/sind am schreiben …"
jumpToSpecifiedDate: "Zu bestimmtem Datum springen"
showingPastTimeline: "Es wird eine alte Chronik angezeigt"

View File

@ -340,9 +340,6 @@ dayX: "{day}"
monthX: "{month}"
yearX: "{year}"
pages: "Pages"
integration: "Integration"
connectService: "Connect"
disconnectService: "Disconnect"
enableLocalTimeline: "Enable local timeline"
enableGlobalTimeline: "Enable global timeline"
disablingTimelinesInfo: "Adminstrators and Moderators will always have access to all\
@ -775,7 +772,6 @@ receiveAnnouncementFromInstance: "Receive notifications from this instance"
emailNotification: "Email notifications"
publish: "Publish"
inChannelSearch: "Search in channel"
useReactionPickerForContextMenu: "Open reaction picker on right-click"
typingUsers: "{users} is/are typing..."
jumpToSpecifiedDate: "Jump to specific date"
showingPastTimeline: "Currently displaying an old timeline"
@ -1512,13 +1508,3 @@ _deck:
list: "List"
mentions: "Mentions"
direct: "Direct notes"
_services:
_discord:
connected: "Discord: @{username}#{discriminator} connected to FoundKey: @{mkUsername}!"
disconnected: "Discord linkage has been removed."
_twitter:
connected: "Twitter: @{twitterUserName} connected to FoundKey: @{userName}!"
disconnected: "Twitter linkage has been removed."
_github:
connected: "GitHub: @{login} connected to FoundKey: @{userName}!"
disconnected: "GitHub linkage has been removed."

View File

@ -342,9 +342,6 @@ dayX: "Día {day}"
monthX: "Mes {month}"
yearX: "Año {year}"
pages: "Páginas"
integration: "Integración"
connectService: "Conectar"
disconnectService: "Desconectar"
enableLocalTimeline: "Habilitar linea de tiempo local"
enableGlobalTimeline: "Habilitar linea de tiempo global"
disablingTimelinesInfo: "Aunque se desactiven estas lineas de tiempo, por conveniencia\
@ -776,8 +773,6 @@ receiveAnnouncementFromInstance: "Recibir notificaciones de la instancia"
emailNotification: "Notificaciones por correo electrónico"
publish: "Publicar"
inChannelSearch: "Buscar en el canal"
useReactionPickerForContextMenu: "Haga clic con el botón derecho para abrir el menu\
\ de reacciones"
typingUsers: "{users} está escribiendo"
jumpToSpecifiedDate: "Saltar a una fecha específica"
showingPastTimeline: "Mostrar líneas de tiempo antiguas"

View File

@ -342,9 +342,6 @@ dayX: "{day}"
monthX: "{month}"
yearX: "{year}"
pages: "Pages"
integration: "Intégrations"
connectService: "Connexion"
disconnectService: "Déconnexion"
enableLocalTimeline: "Activer le fil local"
enableGlobalTimeline: "Activer le fil global"
disablingTimelinesInfo: "Même si vous désactivez ces fils, les administrateur·rice·s\
@ -785,7 +782,6 @@ receiveAnnouncementFromInstance: "Recevoir les messages d'information de l'insta
emailNotification: "Notifications par mail"
publish: "Public"
inChannelSearch: "Chercher dans le canal"
useReactionPickerForContextMenu: "Clic-droit pour ouvrir le panneau de réactions"
typingUsers: "{users} est en train d'écrire"
jumpToSpecifiedDate: "Se rendre à la date"
showingPastTimeline: "Un fil ancien est affiché"

View File

@ -341,9 +341,6 @@ dayX: "{day}"
monthX: "{month}"
yearX: "{year}"
pages: "Halaman"
integration: "Integrasi"
connectService: "Sambungkan"
disconnectService: "Putuskan"
enableLocalTimeline: "Nyalakan linimasa lokal"
enableGlobalTimeline: "Nyalakan linimasa global"
disablingTimelinesInfo: "Admin dan Moderator akan selalu memiliki akses ke semua linimasa\
@ -780,7 +777,6 @@ receiveAnnouncementFromInstance: "Terima pemberitahuan surel dari instansi ini"
emailNotification: "Pemberitahuan surel"
publish: "Terbitkan"
inChannelSearch: "Cari di kanal"
useReactionPickerForContextMenu: "Buka pemilih reaksi dengan klik-kanan"
typingUsers: "{users} sedang mengetik..."
jumpToSpecifiedDate: "Loncat ke tanggal spesifik"
showingPastTimeline: "Sedang menampilkan linimasa lama"

View File

@ -335,9 +335,6 @@ dayX: "{day}"
monthX: "{month}"
yearX: "{year}"
pages: "Pagine"
integration: "App collegate"
connectService: "Connessione"
disconnectService: "Disconnessione "
enableLocalTimeline: "Abilita Timeline locale"
enableGlobalTimeline: "Abilita Timeline federata"
disablingTimelinesInfo: "Anche se disabiliti queste timeline, gli amministratori e\
@ -762,8 +759,6 @@ receiveAnnouncementFromInstance: "Ricevi i messaggi informativi dall'istanza"
emailNotification: "Eventi per notifiche via mail"
publish: "Pubblico"
inChannelSearch: "Cerca in canale"
useReactionPickerForContextMenu: "Cliccare sul tasto destro per aprire il pannello\
\ di reazioni"
typingUsers: "{users} sta(nno) scrivendo"
jumpToSpecifiedDate: "Vai alla data "
showingPastTimeline: "Stai visualizzando una vecchia timeline"

View File

@ -317,9 +317,6 @@ dayX: "{day}日"
monthX: "{month}月"
yearX: "{year}年"
pages: "ページ"
integration: "連携"
connectService: "接続する"
disconnectService: "切断する"
enableLocalTimeline: "ローカルタイムラインを有効にする"
enableGlobalTimeline: "グローバルタイムラインを有効にする"
disablingTimelinesInfo: "これらのタイムラインを無効化しても、利便性のため管理者およびモデレーターは引き続き利用することができます。"
@ -721,7 +718,6 @@ receiveAnnouncementFromInstance: "インスタンスからのお知らせを受
emailNotification: "メール通知"
publish: "公開"
inChannelSearch: "チャンネル内検索"
useReactionPickerForContextMenu: "右クリックでリアクションピッカーを開く"
typingUsers: "{users}が入力中"
jumpToSpecifiedDate: "特定の日付にジャンプ"
showingPastTimeline: "過去のタイムラインを表示しています"
@ -1450,13 +1446,3 @@ _deck:
list: "リスト"
mentions: "あなた宛て"
direct: "ダイレクト"
_services:
_discord:
connected: "Discord: @{username}#{discriminator} を、FoundKey: @{mkUsername} に接続しました!"
disconnected: "Discordの連携を解除しました :v:"
_twitter:
connected: "Twitter: @{twitterUserName} を、FoundKey: @{userName} に接続しました!"
disconnected: "Twitterの連携を解除しました :v:"
_github:
connected: "GitHub: @{login} を、FoundKey: @{userName} に接続しました!"
disconnected: "GitHubの連携を解除しました :v:"

View File

@ -319,7 +319,6 @@ dayX: "{day}日"
monthX: "{month}月"
yearX: "{year}年"
pages: "ページ"
integration: "連携"
enableLocalTimeline: "ローカルタイムラインを使えるようにする"
enableGlobalTimeline: "グローバルタイムラインを使えるようにする"
disablingTimelinesInfo: "ここらへんのタイムラインを使えんようにしてしもても、管理者とモデレーターは使えるままになってるで、そうやなかったら不便やからな。"
@ -633,7 +632,6 @@ apply: "適用"
receiveAnnouncementFromInstance: "インスタンスからのお知らせを受け取る"
emailNotification: "メール通知"
inChannelSearch: "チャンネル内検索"
useReactionPickerForContextMenu: "右クリックでリアクションピッカーを開くようにする"
typingUsers: "{users}が今書きよるで"
jumpToSpecifiedDate: "特定の日付にジャンプ"
showingPastTimeline: "過去のタイムラインを表示してるで"

View File

@ -315,9 +315,6 @@ dayX: "{day}일"
monthX: "{month}월"
yearX: "{year}년"
pages: "페이지"
integration: "연동"
connectService: "계정 연동"
disconnectService: "계정 연동 해제"
enableLocalTimeline: "로컬 타임라인 활성화"
enableGlobalTimeline: "글로벌 타임라인 활성화"
disablingTimelinesInfo: "특정 타임라인을 비활성화하더라도 관리자 및 모더레이터는 계속 사용할 수 있습니다."
@ -716,7 +713,6 @@ receiveAnnouncementFromInstance: "이 인스턴스의 알림을 이메일로 수
emailNotification: "메일 알림"
publish: "게시"
inChannelSearch: "채널에서 검색"
useReactionPickerForContextMenu: "우클릭하여 리액션 선택기 열기"
typingUsers: "{users} 님이 입력하고 있어요.."
jumpToSpecifiedDate: "특정 날짜로 이동"
showingPastTimeline: "과거의 타임라인을 표시하고 있어요"

View File

@ -329,9 +329,6 @@ dayX: "{day}"
monthX: "{month}"
yearX: "{year}"
pages: "Strony"
integration: "Integracja"
connectService: "Połącz"
disconnectService: "Rozłącz"
enableLocalTimeline: "Włącz lokalną oś czasu"
enableGlobalTimeline: "Włącz globalną oś czasu"
disablingTimelinesInfo: "Administratorzy i moderatorzy będą zawsze mieć dostęp do\
@ -737,7 +734,6 @@ receiveAnnouncementFromInstance: "Otrzymuj powiadomienia e-mail z tej instancji"
emailNotification: "Powiadomienia e-mail"
publish: "Publikuj"
inChannelSearch: "Szukaj na kanale"
useReactionPickerForContextMenu: "Otwórz wybornik reakcji prawym kliknięciem"
typingUsers: "{users} pisze(-ą)..."
jumpToSpecifiedDate: "Przejdź do określonej daty"
showingPastTimeline: "Obecnie wyświetla starą oś czasu"

View File

@ -342,9 +342,6 @@ dayX: "{day}"
monthX: "{month}"
yearX: "{year}"
pages: "Pagini"
integration: "Integrare"
connectService: "Conectează"
disconnectService: "Deconectează"
enableLocalTimeline: "Activează cronologia locală"
enableGlobalTimeline: "Activeaza cronologia globală"
disablingTimelinesInfo: "Administratorii și Moderatorii vor avea mereu access la toate\

View File

@ -335,9 +335,6 @@ dayX: "{day} день"
monthX: "{month} месяц"
yearX: "{year} год"
pages: "Страницы"
integration: "Интеграция"
connectService: "Подключиться"
disconnectService: "Отключиться"
enableLocalTimeline: "Включить локальную ленту"
enableGlobalTimeline: "Включить глобальную ленту"
disablingTimelinesInfo: "У администраторов и модераторов есть доступ ко всем лентам,\
@ -770,7 +767,6 @@ receiveAnnouncementFromInstance: "Получать оповещения с ин
emailNotification: "Уведомления по электронной почте"
publish: "Опубликовать"
inChannelSearch: "Поиск по каналу"
useReactionPickerForContextMenu: "Открывать палитру реакций правой кнопкой"
typingUsers: "Стук клавиш. Это {users}…"
jumpToSpecifiedDate: "Перейти к заданной дате"
showingPastTimeline: "Отображается старая лента"

View File

@ -336,9 +336,6 @@ dayX: "{day}"
monthX: "{month}"
yearX: "{year}"
pages: "Stránky"
integration: "Integrácia"
connectService: "Pripojiť"
disconnectService: "Odpojiť"
enableLocalTimeline: "Povoliť lokálnu časovú os"
enableGlobalTimeline: "Povoliť globálnu časovú os"
disablingTimelinesInfo: "Administrátori a moderátori majú vždy prístup ku všetkým\
@ -761,7 +758,6 @@ receiveAnnouncementFromInstance: "Prijať notifikácie z tohoto servera"
emailNotification: "Emailové upozornenia"
publish: "Zverejniť"
inChannelSearch: "Hľadať v kanáli"
useReactionPickerForContextMenu: "Otvoriť výber reakcií na pravý klik"
typingUsers: "{users} píše/u"
jumpToSpecifiedDate: "Skočiť na konkrétny dátum"
showingPastTimeline: "Práve vidíte starú časovú os"

View File

@ -336,9 +336,6 @@ dayX: "{day}"
monthX: "{month}"
yearX: "{year}"
pages: "Сторінки"
integration: "Інтеграція"
connectService: "Під’єднати"
disconnectService: "Відключитися"
enableLocalTimeline: "Увімкнути локальну стрічку"
enableGlobalTimeline: "Увімкнути глобальну стрічку"
disablingTimelinesInfo: "Адміністратори та модератори завжди мають доступ до всіх\
@ -768,7 +765,6 @@ receiveAnnouncementFromInstance: "Отримувати оповіщення з
emailNotification: "Сповіщення електронною поштою"
publish: "Опублікувати"
inChannelSearch: "Пошук за каналом"
useReactionPickerForContextMenu: "Відкривати палітру реакцій правою кнопкою"
typingUsers: "Стук клавіш. Це {users}…"
goBack: "Назад"
info: "Інформація"

View File

@ -336,9 +336,6 @@ dayX: "{day}"
monthX: "{month}"
yearX: "{year}"
pages: "Trang"
integration: "Tương tác"
connectService: "Kết nối"
disconnectService: "Ngắt kết nối"
enableLocalTimeline: "Bật bảng tin máy chủ"
enableGlobalTimeline: "Bật bảng tin liên hợp"
disablingTimelinesInfo: "Quản trị viên và Kiểm duyệt viên luôn có quyền truy cập mọi\
@ -767,7 +764,6 @@ receiveAnnouncementFromInstance: "Nhận thông báo từ máy chủ này"
emailNotification: "Thông báo email"
publish: "Đăng"
inChannelSearch: "Tìm trong kênh"
useReactionPickerForContextMenu: "Nhấn chuột phải để mở bộ chọn biểu cảm"
typingUsers: "{users} đang nhập…"
jumpToSpecifiedDate: "Đến một ngày cụ thể"
showingPastTimeline: "Hiện đang hiển thị dòng thời gian cũ"

View File

@ -315,9 +315,6 @@ dayX: "{day}日"
monthX: "{month}月"
yearX: "{year}年"
pages: "页面"
integration: "关联"
connectService: "连接"
disconnectService: "断开连接"
enableLocalTimeline: "启用本地时间线功能"
enableGlobalTimeline: "启用全局时间线"
disablingTimelinesInfo: "即使时间线功能被禁用,出于方便,管理员和数据图表也可以继续使用。"
@ -717,7 +714,6 @@ receiveAnnouncementFromInstance: "从实例接收通知"
emailNotification: "邮件通知"
publish: "发布"
inChannelSearch: "频道内搜索"
useReactionPickerForContextMenu: "单击右键打开回应工具栏"
typingUsers: "{users}正在输入"
jumpToSpecifiedDate: "跳转到特定日期"
showingPastTimeline: "显示过去的时间线"

View File

@ -315,9 +315,6 @@ dayX: "{day}日"
monthX: "{month}月"
yearX: "{year}年"
pages: "頁面"
integration: "整合"
connectService: "己連結"
disconnectService: "己斷開 "
enableLocalTimeline: "開啟本地時間軸"
enableGlobalTimeline: "啟用公開時間軸"
disablingTimelinesInfo: "即使您關閉了時間線功能,管理員和協調人仍可以繼續使用,以方便您。"
@ -716,7 +713,6 @@ receiveAnnouncementFromInstance: "接收由本實例發出的電郵通知"
emailNotification: "郵件通知"
publish: "發佈"
inChannelSearch: "頻道内搜尋"
useReactionPickerForContextMenu: "點擊右鍵開啟回應工具欄"
typingUsers: "{users}輸入中..."
jumpToSpecifiedDate: "跳轉到特定日期"
showingPastTimeline: "顯示過往的時間線"

View File

@ -32,7 +32,8 @@
},
"resolutions": {
"chokidar": "^3.3.1",
"lodash": "^4.17.21"
"lodash": "^4.17.21",
"@types/node": "^18.7.18"
},
"dependencies": {
"execa": "5.1.1",
@ -48,7 +49,6 @@
"@types/gulp-rename": "2.0.1",
"@typescript-eslint/parser": "^5.36.2",
"cross-env": "7.0.3",
"cypress": "10.3.0",
"start-server-and-test": "1.14.0",
"typescript": "4.8.3"
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 118 KiB

View File

@ -15,8 +15,8 @@
"test": "npm run mocha"
},
"dependencies": {
"@bull-board/api": "^4.2.2",
"@bull-board/koa": "4.0.0",
"@bull-board/api": "^4.3.1",
"@bull-board/koa": "^4.3.1",
"@discordapp/twemoji": "14.0.2",
"@elastic/elasticsearch": "7.11.0",
"@koa/cors": "3.1.0",
@ -29,7 +29,6 @@
"ajv": "8.11.0",
"archiver": "5.3.1",
"autobind-decorator": "2.4.0",
"autwh": "0.1.0",
"aws-sdk": "2.1165.0",
"bcryptjs": "2.4.3",
"blurhash": "1.1.5",
@ -121,13 +120,16 @@
},
"devDependencies": {
"@redocly/openapi-core": "1.0.0-beta.97",
"@types/archiver": "^5.3.1",
"@types/bcryptjs": "2.4.2",
"@types/bull": "3.15.8",
"@types/cbor": "6.0.0",
"@types/color-convert": "^2.0.0",
"@types/escape-regexp": "0.0.1",
"@types/fluent-ffmpeg": "2.1.20",
"@types/is-url": "1.2.30",
"@types/js-yaml": "4.0.5",
"@types/jsbn": "^1.2.30",
"@types/jsdom": "16.2.14",
"@types/jsonld": "1.5.6",
"@types/jsrsasign": "10.5.1",
@ -142,11 +144,12 @@
"@types/koa__cors": "3.1.1",
"@types/koa__multer": "2.0.4",
"@types/koa__router": "8.0.11",
"@types/mime-types": "^2.1.1",
"@types/mocha": "9.1.1",
"@types/node": "18.7.16",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.5",
"@types/oauth": "^0.9.1",
"@types/opentype.js": "^1.3.4",
"@types/pg": "^8.6.5",
"@types/pug": "2.0.6",
"@types/punycode": "2.1.0",
@ -154,18 +157,22 @@
"@types/random-seed": "0.3.3",
"@types/ratelimiter": "3.4.3",
"@types/redis": "4.0.11",
"@types/redis-info": "^3.0.0",
"@types/rename": "1.0.4",
"@types/sanitize-html": "2.6.2",
"@types/semver": "7.3.12",
"@types/sharp": "0.30.5",
"@types/sinonjs__fake-timers": "8.1.2",
"@types/speakeasy": "2.0.7",
"@types/syslog-pro": "^1.0.0",
"@types/tinycolor2": "1.4.3",
"@types/tmp": "0.2.3",
"@types/unzipper": "^0.10.5",
"@types/uuid": "8.3.4",
"@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.3",
"@types/xml2js": "^0.4.11",
"@typescript-eslint/eslint-plugin": "^5.36.2",
"@typescript-eslint/parser": "^5.36.2",
"cross-env": "7.0.3",

View File

@ -0,0 +1,11 @@
declare module 'redis-lock' {
import { promisify } from 'util';
import { Redis } from 'ioredis';
export type unlockFunction = (done?: () => void) => void;
interface lockFactory {
(lockName: string, timeout: number, taskToPerform: (unlock: unlockFunction) => void): void;
[promisify.custom](lockName: string, timeout: number): Promise<unlockFunction>;
}
function lock (client: Redis, retryDelay: number): lockFactory;
export default lock;
}

View File

@ -0,0 +1,4 @@
declare module 'twemoji-parser/dist/lib/regex.js' {
const a: {'default': RegExp};
export default a;
}

View File

@ -1,5 +1,5 @@
import { promisify } from 'node:util';
import redisLock from 'redis-lock';
import redisLock, { unlockFunction } from 'redis-lock';
import { redisClient } from '@/db/redis.js';
/**
@ -18,14 +18,14 @@ const lock: (key: string, timeout?: number) => Promise<() => void>
* @param timeout Lock timeout (ms), The timeout releases previous lock.
* @returns Unlock function
*/
export function getApLock(uri: string, timeout = 30 * 1000) {
return lock(`ap-object:${uri}`, timeout);
export async function getApLock(uri: string, timeout: number = 30 * 1000): Promise<unlockFunction> {
return await lock(`ap-object:${uri}`, timeout);
}
export function getFetchInstanceMetadataLock(host: string, timeout = 30 * 1000) {
return lock(`instance:${host}`, timeout);
export async function getFetchInstanceMetadataLock(host: string, timeout: number = 30 * 1000): Promise<unlockFunction> {
return await lock(`instance:${host}`, timeout);
}
export function getChartInsertLock(lockKey: string, timeout = 30 * 1000) {
return lock(`chart-insert:${lockKey}`, timeout);
export async function getChartInsertLock(lockKey: string, timeout: number = 30 * 1000): Promise<unlockFunction> {
return await lock(`chart-insert:${lockKey}`, timeout);
}

View File

@ -13,7 +13,7 @@ export class I18n<T extends Record<string, any>> {
// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
public t(key: string, args?: Record<string, any>): string {
try {
let str = key.split('.').reduce((o, i) => o[i], this.locale) as string;
let str = key.split('.').reduce((o, i) => o[i] as Record<string, any> | string, this.locale) as string;
if (args) {
for (const [k, v] of Object.entries(args)) {

View File

@ -1,5 +1,5 @@
import { Note } from '@/models/entities/note.js';
export function isPureRenote(note: Note): boolean {
export function isPureRenote(note: Note): note is Note & { renoteId: string, text: null, fileIds: null | never[], hasPoll: false } {
return note.renoteId != null && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !note.hasPoll;
}

View File

@ -34,7 +34,7 @@ export default async (user: { id: User['id'] }, url: string, object: any) => {
* @param user http-signature user
* @param url URL to fetch
*/
export async function signedGet(url: string, user: { id: User['id'] }) {
export async function signedGet(url: string, user: { id: User['id'] }): Promise<any> {
const keypair = await getUserKeypair(user.id);
const req = createSignedGet({

View File

@ -15,8 +15,8 @@ export class AuthenticationError extends Error {
}
}
export default async (authorization: string | null | undefined, bodyToken: string | null): Promise<[CacheableLocalUser | null | undefined, AccessToken | null | undefined]> => {
let token: string | null = null;
export default async (authorization: string | null | undefined, bodyToken: string | null | undefined): Promise<[CacheableLocalUser | null | undefined, AccessToken | null | undefined]> => {
let maybeToken: string | null = null;
// check if there is an authorization header set
if (authorization != null) {
@ -27,15 +27,16 @@ export default async (authorization: string | null | undefined, bodyToken: strin
// check if OAuth 2.0 Bearer tokens are being used
// Authorization schemes are case insensitive
if (authorization.substring(0, 7).toLowerCase() === 'bearer ') {
token = authorization.substring(7);
maybeToken = authorization.substring(7);
} else {
throw new AuthenticationError('unsupported authentication scheme');
}
} else if (bodyToken != null) {
token = bodyToken;
maybeToken = bodyToken;
} else {
return [null, null];
}
const token: string = maybeToken;
if (isNativeToken(token)) {
const user = await localUserByNativeTokenCache.fetch(token,

View File

@ -1,7 +1,7 @@
import { Brackets, SelectQueryBuilder } from 'typeorm';
import { User } from '@/models/entities/user.js';
export function generateRepliesQuery(q: SelectQueryBuilder<any>, me?: Pick<User, 'id' | 'showTimelineReplies'> | null) {
export function generateRepliesQuery(q: SelectQueryBuilder<any>, me?: Pick<User, 'id' | 'showTimelineReplies'> | null, followingQuery: SelectQueryBuilder<any> | null) {
if (me == null) {
q.andWhere(new Brackets(qb => { qb
.where('note.replyId IS NULL') // 返信ではない
@ -14,6 +14,7 @@ export function generateRepliesQuery(q: SelectQueryBuilder<any>, me?: Pick<User,
q.andWhere(new Brackets(qb => { qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信
.orWhere('note.mentions && array[:meId]::varchar[]', { meId: me.id })
.orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信
.where('note.replyId IS NOT NULL')
.andWhere('note.userId = :meId', { meId: me.id });
@ -22,6 +23,12 @@ export function generateRepliesQuery(q: SelectQueryBuilder<any>, me?: Pick<User,
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}));
if (followingQuery !== null) {
qb.orWhere(new Brackets(qb => { qb
.where(`note.mentions && array(${ followingQuery.getQuery() })`)
.setParameters(followingQuery.getParameters())
}))
}
}));
}
}

View File

@ -101,18 +101,6 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
enableTwitterIntegration: {
type: 'boolean',
optional: false, nullable: false,
},
enableGithubIntegration: {
type: 'boolean',
optional: false, nullable: false,
},
enableDiscordIntegration: {
type: 'boolean',
optional: false, nullable: false,
},
enableServiceWorker: {
type: 'boolean',
optional: false, nullable: false,
@ -166,30 +154,6 @@ export const meta = {
optional: true, nullable: true,
format: 'id',
},
twitterConsumerKey: {
type: 'string',
optional: true, nullable: true,
},
twitterConsumerSecret: {
type: 'string',
optional: true, nullable: true,
},
githubClientId: {
type: 'string',
optional: true, nullable: true,
},
githubClientSecret: {
type: 'string',
optional: true, nullable: true,
},
discordClientId: {
type: 'string',
optional: true, nullable: true,
},
discordClientSecret: {
type: 'string',
optional: true, nullable: true,
},
summaryProxy: {
type: 'string',
optional: true, nullable: true,
@ -314,9 +278,6 @@ export default define(meta, paramDef, async (ps, me) => {
defaultLightTheme: instance.defaultLightTheme,
defaultDarkTheme: instance.defaultDarkTheme,
enableEmail: instance.enableEmail,
enableTwitterIntegration: instance.enableTwitterIntegration,
enableGithubIntegration: instance.enableGithubIntegration,
enableDiscordIntegration: instance.enableDiscordIntegration,
enableServiceWorker: instance.enableServiceWorker,
translatorAvailable: instance.deeplAuthKey != null,
pinnedPages: instance.pinnedPages,
@ -330,12 +291,6 @@ export default define(meta, paramDef, async (ps, me) => {
hcaptchaSecretKey: instance.hcaptchaSecretKey,
recaptchaSecretKey: instance.recaptchaSecretKey,
proxyAccountId: instance.proxyAccountId,
twitterConsumerKey: instance.twitterConsumerKey,
twitterConsumerSecret: instance.twitterConsumerSecret,
githubClientId: instance.githubClientId,
githubClientSecret: instance.githubClientSecret,
discordClientId: instance.discordClientId,
discordClientSecret: instance.discordClientSecret,
summalyProxy: instance.summalyProxy,
email: instance.email,
smtpSecure: instance.smtpSecure,

View File

@ -58,15 +58,6 @@ export const paramDef = {
summalyProxy: { type: 'string', nullable: true },
deeplAuthKey: { type: 'string', nullable: true },
deeplIsPro: { type: 'boolean' },
enableTwitterIntegration: { type: 'boolean' },
twitterConsumerKey: { type: 'string', nullable: true },
twitterConsumerSecret: { type: 'string', nullable: true },
enableGithubIntegration: { type: 'boolean' },
githubClientId: { type: 'string', nullable: true },
githubClientSecret: { type: 'string', nullable: true },
enableDiscordIntegration: { type: 'boolean' },
discordClientId: { type: 'string', nullable: true },
discordClientSecret: { type: 'string', nullable: true },
enableEmail: { type: 'boolean' },
email: { type: 'string', nullable: true },
smtpSecure: { type: 'boolean' },
@ -231,42 +222,6 @@ export default define(meta, paramDef, async (ps, me) => {
set.summalyProxy = ps.summalyProxy;
}
if (ps.enableTwitterIntegration !== undefined) {
set.enableTwitterIntegration = ps.enableTwitterIntegration;
}
if (ps.twitterConsumerKey !== undefined) {
set.twitterConsumerKey = ps.twitterConsumerKey;
}
if (ps.twitterConsumerSecret !== undefined) {
set.twitterConsumerSecret = ps.twitterConsumerSecret;
}
if (ps.enableGithubIntegration !== undefined) {
set.enableGithubIntegration = ps.enableGithubIntegration;
}
if (ps.githubClientId !== undefined) {
set.githubClientId = ps.githubClientId;
}
if (ps.githubClientSecret !== undefined) {
set.githubClientSecret = ps.githubClientSecret;
}
if (ps.enableDiscordIntegration !== undefined) {
set.enableDiscordIntegration = ps.enableDiscordIntegration;
}
if (ps.discordClientId !== undefined) {
set.discordClientId = ps.discordClientId;
}
if (ps.discordClientSecret !== undefined) {
set.discordClientSecret = ps.discordClientSecret;
}
if (ps.enableEmail !== undefined) {
set.enableEmail = ps.enableEmail;
}

View File

@ -170,18 +170,6 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
enableTwitterIntegration: {
type: 'boolean',
optional: false, nullable: false,
},
enableGithubIntegration: {
type: 'boolean',
optional: false, nullable: false,
},
enableDiscordIntegration: {
type: 'boolean',
optional: false, nullable: false,
},
enableServiceWorker: {
type: 'boolean',
optional: false, nullable: false,
@ -226,18 +214,6 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
twitter: {
type: 'boolean',
optional: false, nullable: false,
},
github: {
type: 'boolean',
optional: false, nullable: false,
},
discord: {
type: 'boolean',
optional: false, nullable: false,
},
serviceWorker: {
type: 'boolean',
optional: false, nullable: false,
@ -317,10 +293,6 @@ export default define(meta, paramDef, async (ps, me) => {
defaultDarkTheme: instance.defaultDarkTheme,
enableEmail: instance.enableEmail,
enableTwitterIntegration: instance.enableTwitterIntegration,
enableGithubIntegration: instance.enableGithubIntegration,
enableDiscordIntegration: instance.enableDiscordIntegration,
enableServiceWorker: instance.enableServiceWorker,
translatorAvailable: instance.deeplAuthKey != null,
@ -343,9 +315,6 @@ export default define(meta, paramDef, async (ps, me) => {
hcaptcha: instance.enableHcaptcha,
recaptcha: instance.enableRecaptcha,
objectStorage: instance.useObjectStorage,
twitter: instance.enableTwitterIntegration,
github: instance.enableGithubIntegration,
discord: instance.enableDiscordIntegration,
serviceWorker: instance.enableServiceWorker,
miauth: true,
},

View File

@ -75,7 +75,7 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
generateRepliesQuery(query, user);
generateRepliesQuery(query, user, null);
if (user) {
generateMutedUserQuery(query, user);
generateMutedNoteQuery(query, user);

View File

@ -89,7 +89,7 @@ export default define(meta, paramDef, async (ps, user) => {
.setParameters(followingQuery.getParameters());
generateChannelQuery(query, user);
generateRepliesQuery(query, user);
generateRepliesQuery(query, user, followingQuery);
generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user);
generateMutedNoteQuery(query, user);

View File

@ -82,7 +82,7 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
generateChannelQuery(query, user);
generateRepliesQuery(query, user);
generateRepliesQuery(query, user, null);
generateVisibilityQuery(query, user);
if (user) generateMutedUserQuery(query, user);
if (user) generateMutedNoteQuery(query, user);

View File

@ -81,7 +81,7 @@ export default define(meta, paramDef, async (ps, user) => {
.setParameters(followingQuery.getParameters());
generateChannelQuery(query, user);
generateRepliesQuery(query, user);
generateRepliesQuery(query, user, followingQuery);
generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user);
generateMutedNoteQuery(query, user);

View File

@ -15,9 +15,6 @@ import handler from './api-handler.js';
import signup from './private/signup.js';
import signin from './private/signin.js';
import signupPending from './private/signup-pending.js';
import discord from './service/discord.js';
import github from './service/github.js';
import twitter from './service/twitter.js';
// Init app
const app = new Koa();
@ -81,10 +78,6 @@ router.post('/signup', signup);
router.post('/signin', signin);
router.post('/signup-pending', signupPending);
router.use(discord.routes());
router.use(github.routes());
router.use(twitter.routes());
router.get('/v1/instance/peers', async ctx => {
const instances = await Instances.find({
select: ['host'],

View File

@ -1,298 +0,0 @@
import Koa from 'koa';
import Router from '@koa/router';
import { OAuth2 } from 'oauth';
import { v4 as uuid } from 'uuid';
import { IsNull } from 'typeorm';
import { getJson } from '@/misc/fetch.js';
import config from '@/config/index.js';
import { publishMainStream } from '@/services/stream.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { Users, UserProfiles } from '@/models/index.js';
import { ILocalUser } from '@/models/entities/user.js';
import { redisClient } from '@/db/redis.js';
import { I18n } from '@/misc/i18n.js';
import signin from '../common/signin.js';
function getUserToken(ctx: Koa.BaseContext): string | null {
return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1];
}
function compareOrigin(ctx: Koa.BaseContext): boolean {
function normalizeUrl(url?: string): string {
return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : '';
}
const referer = ctx.headers['referer'];
return (normalizeUrl(referer) === normalizeUrl(config.url));
}
const locales = await import('../../../../../../locales/index.js').then(mod => mod.default);
// Init router
const router = new Router();
router.get('/disconnect/discord', async ctx => {
if (!compareOrigin(ctx)) {
ctx.throw(400, 'invalid origin');
return;
}
const userToken = getUserToken(ctx);
if (!userToken) {
ctx.throw(400, 'signin required');
return;
}
const user = await Users.findOneByOrFail({
host: IsNull(),
token: userToken,
});
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
const locale = locales[profile.lang || 'en-US'];
const i18n = new I18n(locale);
delete profile.integrations.discord;
await UserProfiles.update(user.id, {
integrations: profile.integrations,
});
ctx.body = i18n.t('_services._discord.disconnected');
// Publish i updated event
publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, {
detail: true,
includeSecrets: true,
}));
});
async function getOAuth2() {
const meta = await fetchMeta(true);
if (meta.enableDiscordIntegration) {
return new OAuth2(
meta.discordClientId!,
meta.discordClientSecret!,
'https://discord.com/',
'api/oauth2/authorize',
'api/oauth2/token');
} else {
return null;
}
}
router.get('/connect/discord', async ctx => {
if (!compareOrigin(ctx)) {
ctx.throw(400, 'invalid origin');
return;
}
const userToken = getUserToken(ctx);
if (!userToken) {
ctx.throw(400, 'signin required');
return;
}
const params = {
redirect_uri: `${config.url}/api/dc/cb`,
scope: ['identify'],
state: uuid(),
response_type: 'code',
};
redisClient.set(userToken, JSON.stringify(params));
const oauth2 = await getOAuth2();
ctx.redirect(oauth2!.getAuthorizeUrl(params));
});
router.get('/signin/discord', async ctx => {
const sessid = uuid();
const params = {
redirect_uri: `${config.url}/api/dc/cb`,
scope: ['identify'],
state: uuid(),
response_type: 'code',
};
ctx.cookies.set('signin_with_discord_sid', sessid, {
path: '/',
secure: config.url.startsWith('https'),
httpOnly: true,
});
redisClient.set(sessid, JSON.stringify(params));
const oauth2 = await getOAuth2();
ctx.redirect(oauth2!.getAuthorizeUrl(params));
});
router.get('/dc/cb', async ctx => {
const userToken = getUserToken(ctx);
const oauth2 = await getOAuth2();
if (!userToken) {
const sessid = ctx.cookies.get('signin_with_discord_sid');
if (!sessid) {
ctx.throw(400, 'invalid session');
return;
}
const code = ctx.query.code;
if (!code || typeof code !== 'string') {
ctx.throw(400, 'invalid session');
return;
}
const { redirect_uri, state } = await new Promise<any>((res, rej) => {
redisClient.get(sessid, async (_, state) => {
res(JSON.parse(state));
});
});
if (ctx.query.state !== state) {
ctx.throw(400, 'invalid session');
return;
}
const { accessToken, refreshToken, expiresDate } = await new Promise<any>((res, rej) =>
oauth2!.getOAuthAccessToken(code, {
grant_type: 'authorization_code',
redirect_uri,
}, (err, accessToken, refreshToken, result) => {
if (err) {
rej(err);
} else if (result.error) {
rej(result.error);
} else {
res({
accessToken,
refreshToken,
expiresDate: Date.now() + Number(result.expires_in) * 1000,
});
}
}));
const { id, username, discriminator } = (await getJson('https://discord.com/api/users/@me', '*/*', 10 * 1000, {
'Authorization': `Bearer ${accessToken}`,
})) as Record<string, unknown>;
if (typeof id !== 'string' || typeof username !== 'string' || typeof discriminator !== 'string') {
ctx.throw(400, 'invalid session');
return;
}
const profile = await UserProfiles.createQueryBuilder()
.where('"integrations"->\'discord\'->>\'id\' = :id', { id })
.andWhere('"userHost" IS NULL')
.getOne();
if (profile == null) {
ctx.throw(404, `There were no FoundKey accounts linked to @${username}#${discriminator}...`);
return;
}
await UserProfiles.update(profile.userId, {
integrations: {
...profile.integrations,
discord: {
id,
accessToken,
refreshToken,
expiresDate,
username,
discriminator,
},
},
});
signin(ctx, await Users.findOneBy({ id: profile.userId }) as ILocalUser, true);
} else {
const code = ctx.query.code;
if (!code || typeof code !== 'string') {
ctx.throw(400, 'invalid session');
return;
}
const { redirect_uri, state } = await new Promise<any>((res, rej) => {
redisClient.get(userToken, async (_, state) => {
res(JSON.parse(state));
});
});
if (ctx.query.state !== state) {
ctx.throw(400, 'invalid session');
return;
}
const { accessToken, refreshToken, expiresDate } = await new Promise<any>((res, rej) =>
oauth2!.getOAuthAccessToken(code, {
grant_type: 'authorization_code',
redirect_uri,
}, (err, accessToken, refreshToken, result) => {
if (err) {
rej(err);
} else if (result.error) {
rej(result.error);
} else {
res({
accessToken,
refreshToken,
expiresDate: Date.now() + Number(result.expires_in) * 1000,
});
}
}));
const { id, username, discriminator } = (await getJson('https://discord.com/api/users/@me', '*/*', 10 * 1000, {
'Authorization': `Bearer ${accessToken}`,
})) as Record<string, unknown>;
if (typeof id !== 'string' || typeof username !== 'string' || typeof discriminator !== 'string') {
ctx.throw(400, 'invalid session');
return;
}
const user = await Users.findOneByOrFail({
host: IsNull(),
token: userToken,
});
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
const locale = locales[profile.lang || 'en-US'];
const i18n = new I18n(locale);
await UserProfiles.update(user.id, {
integrations: {
...profile.integrations,
discord: {
accessToken,
refreshToken,
expiresDate,
id,
username,
discriminator,
},
},
});
ctx.body = i18n.t('_services._discord.connected', {
username,
discriminator,
mkUsername: user.username,
});
// Publish i updated event
publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, {
detail: true,
includeSecrets: true,
}));
}
});
export default router;

View File

@ -1,269 +0,0 @@
import Koa from 'koa';
import Router from '@koa/router';
import { OAuth2 } from 'oauth';
import { v4 as uuid } from 'uuid';
import { IsNull } from 'typeorm';
import { getJson } from '@/misc/fetch.js';
import config from '@/config/index.js';
import { publishMainStream } from '@/services/stream.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { Users, UserProfiles } from '@/models/index.js';
import { ILocalUser } from '@/models/entities/user.js';
import { redisClient } from '@/db/redis.js';
import signin from '../common/signin.js';
import { I18n } from '@/misc/i18n.js';
function getUserToken(ctx: Koa.BaseContext): string | null {
return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1];
}
function compareOrigin(ctx: Koa.BaseContext): boolean {
function normalizeUrl(url?: string): string {
return url ? url.endsWith('/') ? url.substr(0, url.length - 1) : url : '';
}
const referer = ctx.headers['referer'];
return (normalizeUrl(referer) === normalizeUrl(config.url));
}
const locales = await import('../../../../../../locales/index.js').then(mod => mod.default);
// Init router
const router = new Router();
router.get('/disconnect/github', async ctx => {
if (!compareOrigin(ctx)) {
ctx.throw(400, 'invalid origin');
return;
}
const userToken = getUserToken(ctx);
if (!userToken) {
ctx.throw(400, 'signin required');
return;
}
const user = await Users.findOneByOrFail({
host: IsNull(),
token: userToken,
});
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
const locale = locales[profile.lang || 'en-US'];
const i18n = new I18n(locale);
delete profile.integrations.github;
await UserProfiles.update(user.id, {
integrations: profile.integrations,
});
ctx.body = i18n.t('_services._github.disconnected');
// Publish i updated event
publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, {
detail: true,
includeSecrets: true,
}));
});
async function getOath2() {
const meta = await fetchMeta(true);
if (meta.enableGithubIntegration && meta.githubClientId && meta.githubClientSecret) {
return new OAuth2(
meta.githubClientId,
meta.githubClientSecret,
'https://github.com/',
'login/oauth/authorize',
'login/oauth/access_token');
} else {
return null;
}
}
router.get('/connect/github', async ctx => {
if (!compareOrigin(ctx)) {
ctx.throw(400, 'invalid origin');
return;
}
const userToken = getUserToken(ctx);
if (!userToken) {
ctx.throw(400, 'signin required');
return;
}
const params = {
redirect_uri: `${config.url}/api/gh/cb`,
scope: ['read:user'],
state: uuid(),
};
redisClient.set(userToken, JSON.stringify(params));
const oauth2 = await getOath2();
ctx.redirect(oauth2!.getAuthorizeUrl(params));
});
router.get('/signin/github', async ctx => {
const sessid = uuid();
const params = {
redirect_uri: `${config.url}/api/gh/cb`,
scope: ['read:user'],
state: uuid(),
};
ctx.cookies.set('signin_with_github_sid', sessid, {
path: '/',
secure: config.url.startsWith('https'),
httpOnly: true,
});
redisClient.set(sessid, JSON.stringify(params));
const oauth2 = await getOath2();
ctx.redirect(oauth2!.getAuthorizeUrl(params));
});
router.get('/gh/cb', async ctx => {
const userToken = getUserToken(ctx);
const oauth2 = await getOath2();
if (!userToken) {
const sessid = ctx.cookies.get('signin_with_github_sid');
if (!sessid) {
ctx.throw(400, 'invalid session');
return;
}
const code = ctx.query.code;
if (!code || typeof code !== 'string') {
ctx.throw(400, 'invalid session');
return;
}
const { redirect_uri, state } = await new Promise<any>((res, rej) => {
redisClient.get(sessid, async (_, state) => {
res(JSON.parse(state));
});
});
if (ctx.query.state !== state) {
ctx.throw(400, 'invalid session');
return;
}
const { accessToken } = await new Promise<any>((res, rej) =>
oauth2!.getOAuthAccessToken(code, {
redirect_uri,
}, (err, accessToken, refresh, result) => {
if (err) {
rej(err);
} else if (result.error) {
rej(result.error);
} else {
res({ accessToken });
}
}));
const { login, id } = (await getJson('https://api.github.com/user', 'application/vnd.github.v3+json', 10 * 1000, {
'Authorization': `bearer ${accessToken}`,
})) as Record<string, unknown>;
if (typeof login !== 'string' || typeof id !== 'string') {
ctx.throw(400, 'invalid session');
return;
}
const link = await UserProfiles.createQueryBuilder()
.where('"integrations"->\'github\'->>\'id\' = :id', { id })
.andWhere('"userHost" IS NULL')
.getOne();
if (link == null) {
ctx.throw(404, `There were no FoundKey accounts linked to @${login}...`);
return;
}
signin(ctx, await Users.findOneBy({ id: link.userId }) as ILocalUser, true);
} else {
const code = ctx.query.code;
if (!code || typeof code !== 'string') {
ctx.throw(400, 'invalid session');
return;
}
const { redirect_uri, state } = await new Promise<any>((res, rej) => {
redisClient.get(userToken, async (_, state) => {
res(JSON.parse(state));
});
});
if (ctx.query.state !== state) {
ctx.throw(400, 'invalid session');
return;
}
const { accessToken } = await new Promise<any>((res, rej) =>
oauth2!.getOAuthAccessToken(
code,
{ redirect_uri },
(err, accessToken, refresh, result) => {
if (err) {
rej(err);
} else if (result.error) {
rej(result.error);
} else {
res({ accessToken });
}
}));
const { login, id } = (await getJson('https://api.github.com/user', 'application/vnd.github.v3+json', 10 * 1000, {
'Authorization': `bearer ${accessToken}`,
})) as Record<string, unknown>;
if (typeof login !== 'string' || typeof id !== 'string') {
ctx.throw(400, 'invalid session');
return;
}
const user = await Users.findOneByOrFail({
host: IsNull(),
token: userToken,
});
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
const locale = locales[profile.lang || 'en-US'];
const i18n = new I18n(locale);
await UserProfiles.update(user.id, {
integrations: {
...profile.integrations,
github: {
accessToken,
id,
login,
},
},
});
ctx.body = i18n.t('_services._github.connected', {
login,
userName: user.username,
});
// Publish i updated event
publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, {
detail: true,
includeSecrets: true,
}));
}
});
export default router;

View File

@ -1,211 +0,0 @@
import Koa from 'koa';
import Router from '@koa/router';
import { v4 as uuid } from 'uuid';
import autwh from 'autwh';
import { IsNull } from 'typeorm';
import { publishMainStream } from '@/services/stream.js';
import config from '@/config/index.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { Users, UserProfiles } from '@/models/index.js';
import { ILocalUser } from '@/models/entities/user.js';
import { redisClient } from '@/db/redis.js';
import signin from '../common/signin.js';
import { I18n } from '@/misc/i18n.js';
function getUserToken(ctx: Koa.BaseContext): string | null {
return ((ctx.headers['cookie'] || '').match(/igi=(\w+)/) || [null, null])[1];
}
function compareOrigin(ctx: Koa.BaseContext): boolean {
function normalizeUrl(url?: string): string {
return url == null ? '' : url.endsWith('/') ? url.substr(0, url.length - 1) : url;
}
const referer = ctx.headers['referer'];
return (normalizeUrl(referer) === normalizeUrl(config.url));
}
const locales = await import('../../../../../../locales/index.js').then(mod => mod.default);
// Init router
const router = new Router();
router.get('/disconnect/twitter', async ctx => {
if (!compareOrigin(ctx)) {
ctx.throw(400, 'invalid origin');
return;
}
const userToken = getUserToken(ctx);
if (userToken == null) {
ctx.throw(400, 'signin required');
return;
}
const user = await Users.findOneByOrFail({
host: IsNull(),
token: userToken,
});
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
const locale = locales[profile.lang || 'en-US'];
const i18n = new I18n(locale);
delete profile.integrations.twitter;
await UserProfiles.update(user.id, {
integrations: profile.integrations,
});
ctx.body = i18n.t('_services._twitter.disconnected');
// Publish i updated event
publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, {
detail: true,
includeSecrets: true,
}));
});
async function getTwAuth() {
const meta = await fetchMeta(true);
if (meta.enableTwitterIntegration && meta.twitterConsumerKey && meta.twitterConsumerSecret) {
return autwh({
consumerKey: meta.twitterConsumerKey,
consumerSecret: meta.twitterConsumerSecret,
callbackUrl: `${config.url}/api/tw/cb`,
});
} else {
return null;
}
}
router.get('/connect/twitter', async ctx => {
if (!compareOrigin(ctx)) {
ctx.throw(400, 'invalid origin');
return;
}
const userToken = getUserToken(ctx);
if (userToken == null) {
ctx.throw(400, 'signin required');
return;
}
const twAuth = await getTwAuth();
const twCtx = await twAuth!.begin();
redisClient.set(userToken, JSON.stringify(twCtx));
ctx.redirect(twCtx.url);
});
router.get('/signin/twitter', async ctx => {
const twAuth = await getTwAuth();
const twCtx = await twAuth!.begin();
const sessid = uuid();
redisClient.set(sessid, JSON.stringify(twCtx));
ctx.cookies.set('signin_with_twitter_sid', sessid, {
path: '/',
secure: config.url.startsWith('https'),
httpOnly: true,
});
ctx.redirect(twCtx.url);
});
router.get('/tw/cb', async ctx => {
const userToken = getUserToken(ctx);
const twAuth = await getTwAuth();
if (userToken == null) {
const sessid = ctx.cookies.get('signin_with_twitter_sid');
if (sessid == null) {
ctx.throw(400, 'invalid session');
return;
}
const get = new Promise<any>((res, rej) => {
redisClient.get(sessid, async (_, twCtx) => {
res(twCtx);
});
});
const twCtx = await get;
const verifier = ctx.query.oauth_verifier;
if (!verifier || typeof verifier !== 'string') {
ctx.throw(400, 'invalid session');
return;
}
const result = await twAuth!.done(JSON.parse(twCtx), verifier);
const link = await UserProfiles.createQueryBuilder()
.where('"integrations"->\'twitter\'->>\'userId\' = :id', { id: result.userId })
.andWhere('"userHost" IS NULL')
.getOne();
if (link == null) {
ctx.throw(404, `There were no FoundKey accounts linked to @${result.screenName}...`);
return;
}
signin(ctx, await Users.findOneBy({ id: link.userId }) as ILocalUser, true);
} else {
const verifier = ctx.query.oauth_verifier;
if (!verifier || typeof verifier !== 'string') {
ctx.throw(400, 'invalid session');
return;
}
const get = new Promise<any>((res, rej) => {
redisClient.get(userToken, async (_, twCtx) => {
res(twCtx);
});
});
const twCtx = await get;
const result = await twAuth!.done(JSON.parse(twCtx), verifier);
const user = await Users.findOneByOrFail({
host: IsNull(),
token: userToken,
});
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
const locale = locales[profile.lang || 'en-US'];
const i18n = new I18n(locale);
await UserProfiles.update(user.id, {
integrations: {
...profile.integrations,
twitter: {
accessToken: result.accessToken,
accessTokenSecret: result.accessTokenSecret,
userId: result.userId,
screenName: result.screenName,
},
},
});
ctx.body = i18n.t('_services._twitter.connected', {
twitterUserName: result.screenName,
userName: user.username,
});
// Publish i updated event
publishMainStream(user.id, 'meUpdated', await Users.pack(user, user, {
detail: true,
includeSecrets: true,
}));
}
});
export default router;

View File

@ -62,21 +62,21 @@ export default abstract class Channel {
});
}
protected withPackedNote(callback: (note: Packed<'Note'>) => void): (Note) => void {
protected withPackedNote(callback: (note: Packed<'Note'>) => Promise<void>): (note: Note) => Promise<void> {
return async (note: Note) => {
try {
// because `note` was previously JSON.stringify'ed, the fields that
// were objects before are now strings and have to be restored or
// removed from the object
note.createdAt = new Date(note.createdAt);
delete note.reply;
delete note.renote;
delete note.user;
delete note.channel;
note.reply = null;
note.renote = null;
note.user = null;
note.channel = null;
const packed = await Notes.pack(note, this.user, { detail: true });
callback(packed);
await callback(packed);
} catch (err) {
if (err instanceof IdentifiableError && err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') {
// skip: note not visible to user

View File

@ -2,6 +2,7 @@ import { Users } from '@/models/index.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { User } from '@/models/entities/user.js';
import { Packed } from '@/misc/schema.js';
import { Note } from '@/models/entities/note.js';
import { StreamMessages } from '../types.js';
import Channel from '../channel.js';
@ -12,10 +13,11 @@ export default class extends Channel {
private channelId: string;
private typers: Record<User['id'], Date> = {};
private emitTypersIntervalId: ReturnType<typeof setInterval>;
private onNote: (note: Note) => Promise<void>;
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
this.onNote = this.withPackedNote(this.onNote.bind(this));
this.onNote = this.withPackedNote(this.onPackedNote.bind(this));
}
public async init(params: any) {
@ -27,7 +29,7 @@ export default class extends Channel {
this.emitTypersIntervalId = setInterval(this.emitTypers, 5000);
}
private async onNote(note: Packed<'Note'>) {
private async onPackedNote(note: Packed<'Note'>): Promise<void> {
if (note.channelId !== this.channelId) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する

View File

@ -3,16 +3,18 @@ import { checkWordMute } from '@/misc/check-word-mute.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { Packed } from '@/misc/schema.js';
import { Note } from '@/models/entities/note.js';
import Channel from '../channel.js';
export default class extends Channel {
public readonly chName = 'globalTimeline';
public static shouldShare = true;
public static requireCredential = false;
private onNote: (note: Note) => Promise<void>;
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
this.onNote = this.withPackedNote(this.onNote.bind(this));
this.onNote = this.withPackedNote(this.onPackedNote.bind(this));
}
public async init(params: any) {
@ -25,7 +27,7 @@ export default class extends Channel {
this.subscriber.on('notesStream', this.onNote);
}
private async onNote(note: Packed<'Note'>) {
private async onPackedNote(note: Packed<'Note'>): Promise<void> {
if (note.visibility !== 'public') return;
if (note.channelId != null) return;

View File

@ -1,6 +1,7 @@
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { Packed } from '@/misc/schema.js';
import { Note } from '@/models/entities/note.js';
import Channel from '../channel.js';
export default class extends Channel {
@ -8,10 +9,11 @@ export default class extends Channel {
public static shouldShare = false;
public static requireCredential = false;
private q: string[][];
private onNote: (note: Note) => Promise<void>;
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
this.onNote = this.withPackedNote(this.onNote.bind(this));
this.onNote = this.withPackedNote(this.onPackedNote.bind(this));
}
public async init(params: any) {
@ -23,7 +25,7 @@ export default class extends Channel {
this.subscriber.on('notesStream', this.onNote);
}
private async onNote(note: Packed<'Note'>) {
private async onPackedNote(note: Packed<'Note'>): Promise<void> {
const noteTags = note.tags ? note.tags.map((t: string) => t.toLowerCase()) : [];
const matched = this.q.some(tags => tags.every(tag => noteTags.includes(normalizeForSearch(tag))));
if (!matched) return;

View File

@ -2,16 +2,18 @@ import { checkWordMute } from '@/misc/check-word-mute.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { Packed } from '@/misc/schema.js';
import { Note } from '@/models/entities/note.js';
import Channel from '../channel.js';
export default class extends Channel {
public readonly chName = 'homeTimeline';
public static shouldShare = true;
public static requireCredential = true;
private onNote: (note: Note) => Promise<void>;
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
this.onNote = this.withPackedNote(this.onNote.bind(this));
this.onNote = this.withPackedNote(this.onPackedNote.bind(this));
}
public async init(params: any) {
@ -19,7 +21,7 @@ export default class extends Channel {
this.subscriber.on('notesStream', this.onNote);
}
private async onNote(note: Packed<'Note'>) {
private async onPackedNote(note: Packed<'Note'>): Promise<void> {
if (note.channelId) {
if (!this.followingChannels.has(note.channelId)) return;
} else {

View File

@ -3,16 +3,18 @@ import { checkWordMute } from '@/misc/check-word-mute.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { Packed } from '@/misc/schema.js';
import { Note } from '@/models/entities/note.js';
import Channel from '../channel.js';
export default class extends Channel {
public readonly chName = 'hybridTimeline';
public static shouldShare = true;
public static requireCredential = true;
private onNote: (note: Note) => Promise<void>;
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
this.onNote = this.withPackedNote(this.onNote.bind(this));
this.onNote = this.withPackedNote(this.onPackedNote.bind(this));
}
public async init(params: any) {
@ -23,7 +25,7 @@ export default class extends Channel {
this.subscriber.on('notesStream', this.onNote);
}
private async onNote(note: Packed<'Note'>) {
private async onPackedNote(note: Packed<'Note'>): Promise<void> {
// チャンネルの投稿ではなく、自分自身の投稿 または
// チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または
// チャンネルの投稿ではなく、全体公開のローカルの投稿 または
@ -38,11 +40,8 @@ export default class extends Channel {
// Ignore notes from instances the user has muted
if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return;
// 関係ない返信は除外
if (note.reply && !this.user!.showTimelineReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
if (note.reply && note.mentions && !this.user!.showTimelineReplies) {
if (!note.mentions.includes(this.user!.id) && !note.mentions.some((user: string) => this.following.has(user))) return;
}
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する

View File

@ -2,16 +2,18 @@ import { fetchMeta } from '@/misc/fetch-meta.js';
import { checkWordMute } from '@/misc/check-word-mute.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { Packed } from '@/misc/schema.js';
import { Note } from '@/models/entities/note.js';
import Channel from '../channel.js';
export default class extends Channel {
public readonly chName = 'localTimeline';
public static shouldShare = true;
public static requireCredential = false;
onNote: (note: Note) => Promise<void>;
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
this.onNote = this.withPackedNote(this.onNote.bind(this));
this.onNote = this.withPackedNote(this.onPackedNote.bind(this));
}
public async init(params: any) {
@ -24,7 +26,7 @@ export default class extends Channel {
this.subscriber.on('notesStream', this.onNote);
}
private async onNote(note: Packed<'Note'>) {
private async onPackedNote(note: Packed<'Note'>): Promise<void> {
if (note.user.host !== null) return;
if (note.visibility !== 'public') return;
if (note.channelId != null && !this.followingChannels.has(note.channelId)) return;

View File

@ -2,6 +2,7 @@ import { UserListJoinings, UserLists } from '@/models/index.js';
import { User } from '@/models/entities/user.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { Packed } from '@/misc/schema.js';
import { Note } from '@/models/entities/note.js';
import Channel from '../channel.js';
export default class extends Channel {
@ -11,11 +12,12 @@ export default class extends Channel {
private listId: string;
public listUsers: User['id'][] = [];
private listUsersClock: NodeJS.Timer;
private onNote: (note: Note) => Promise<void>;
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
this.updateListUsers = this.updateListUsers.bind(this);
this.onNote = this.withPackedNote(this.onNote.bind(this));
this.onNote = this.withPackedNote(this.onPackedNote.bind(this));
}
public async init(params: any) {
@ -48,7 +50,7 @@ export default class extends Channel {
this.listUsers = users.map(x => x.userId);
}
private async onNote(note: Packed<'Note'>) {
private async onPackedNote(note: Packed<'Note'>): Promise<void> {
if (!this.listUsers.includes(note.userId)) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する

View File

@ -1,5 +1,4 @@
import { EventEmitter } from 'events';
import Emitter from 'strict-event-emitter-types';
import { Channel } from '@/models/entities/channel.js';
import { User } from '@/models/entities/user.js';
import { UserProfile } from '@/models/entities/user-profile.js';
@ -15,6 +14,7 @@ import { Signin } from '@/models/entities/signin.js';
import { Page } from '@/models/entities/page.js';
import { Packed } from '@/misc/schema.js';
import { Webhook } from '@/models/entities/webhook.js';
import type { StrictEventEmitter as Emitter } from 'strict-event-emitter-types';
//#region Stream type-body definitions
export interface InternalStreamTypes {

View File

@ -16,6 +16,10 @@ export const initializeStreamingServer = (server: http.Server): void => {
ws.on('request', async (request): Promise<void> => {
const q = request.resourceURL.query as ParsedUrlQuery;
if (q.i instanceof Array) {
request.reject(400);
return;
}
const [user, app] = await authenticate(request.httpRequest.headers.authorization, q.i)
.catch(err => {

View File

@ -138,13 +138,13 @@ export const startServer = () => {
return server;
};
export default () => new Promise(resolve => {
export default (): Promise<void> => new Promise(resolve => {
const server = createServer();
initializeStreamingServer(server);
server.on('error', e => {
switch ((e as any).code) {
switch ((e as NodeJS.ErrnoException).code) {
case 'EACCES':
serverLogger.error(`You do not have permission to listen on port ${config.port}.`);
break;
@ -164,5 +164,5 @@ export default () => new Promise(resolve => {
}
});
server.listen(config.port, resolve);
server.listen(config.port, () => resolve());
});

View File

@ -72,9 +72,6 @@ const nodeinfo2 = async () => {
enableHcaptcha: meta.enableHcaptcha,
enableRecaptcha: meta.enableRecaptcha,
maxNoteTextLength: MAX_NOTE_TEXT_LENGTH,
enableTwitterIntegration: meta.enableTwitterIntegration,
enableGithubIntegration: meta.enableGithubIntegration,
enableDiscordIntegration: meta.enableDiscordIntegration,
enableEmail: meta.enableEmail,
enableServiceWorker: meta.enableServiceWorker,
proxyAccountName: proxyAccount ? proxyAccount.username : null,

View File

@ -13,13 +13,6 @@
// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
(async () => {
window.onerror = (e) => {
renderError('SOMETHING_HAPPENED', e);
};
window.onunhandledrejection = (e) => {
renderError('SOMETHING_HAPPENED_IN_PROMISE', e);
};
const v = localStorage.getItem('v') || VERSION;
//#region Detect language & fetch translations
@ -102,54 +95,6 @@
document.head.appendChild(style);
}
// eslint-disable-next-line no-inner-declarations
function renderError(code, details) {
let errorsElement = document.getElementById('errors');
if (!errorsElement) {
document.getElementsByTagName("head")[0].insertAdjacentHTML(
"beforeend",
`<link rel="stylesheet" href="../error.css" />`);
document.documentElement.innerHTML = `
<svg class="icon-warning" xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-alert-triangle" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 9v2m0 4v.01"></path>
<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path>
</svg>
<h1>An error has occurred!</h1>
<button class="button-big" onclick="location.reload(true);">
<span class="button-label-big">Refresh</span>
</button>
<p class="dont-worry">Don't worry, it's (probably) not your fault.</p>
<p>If the problem persists after refreshing, please contact your instance's administrator.<br>You may also try the following options:</p>
<a href="/flush">
<button class="button-small">
<span class="button-label-small">Flush preferences and cache</span>
</button>
</a>
<br>
<a href="/cli">
<button class="button-small">
<span class="button-label-small">Start the simple client</span>
</button>
</a>
<br>
<a href="/bios">
<button class="button-small">
<span class="button-label-small">Attempt to repair in Repair Tool</span>
</button>
</a>
<br>
<div id="errors"></div>
`;
errorsElement = document.getElementById('errors');
}
const detailsElement = document.createElement('details');
detailsElement.innerHTML = `<br><summary><code>ERROR CODE: ${code}</code></summary>${JSON.stringify(details)}`;
errorsElement.appendChild(detailsElement);
}
// eslint-disable-next-line no-inner-declarations
async function checkUpdate() {
// TODO: サーバーが落ちている場合などのエラーハンドリング

View File

@ -31,7 +31,7 @@ export const urlPreviewHandler = async (ctx: Koa.Context): Promise<void> => {
const summary = meta.summalyProxy ? await getJson(`${meta.summalyProxy}?${query({
url,
lang: lang ?? 'ja-JP',
})}`) : await summaly.default(url, {
})}`) as Awaited<ReturnType<typeof summaly.default>> : await summaly.default(url, {
followRedirects: false,
lang: lang ?? 'ja-JP',
});
@ -53,8 +53,8 @@ export const urlPreviewHandler = async (ctx: Koa.Context): Promise<void> => {
}
};
function wrap(url?: string): string | null {
return url != null
function wrap(url: string|null): string|null {
return url !== null
? url.match(/^https?:\/\//)
? `${config.url}/proxy/preview.webp?${query({
url,

View File

@ -104,7 +104,7 @@ async function fetchNodeinfo(instance: Instance): Promise<NodeInfo> {
throw new Error('No wellknown links');
}
const links = wellknown.links as any[];
const links = wellknown.links;
const lnik1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0');
const lnik2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0');

View File

@ -6,9 +6,10 @@ import * as SyslogPro from 'syslog-pro';
import config from '@/config/index.js';
import { envOption } from '@/env.js';
type KeywordColor = Parameters<typeof convertColor.keyword.rgb>[0];
type Domain = {
name: string;
color?: string;
color?: KeywordColor;
};
type Level = 'error' | 'success' | 'warning' | 'debug' | 'info';
@ -17,9 +18,9 @@ export default class Logger {
private domain: Domain;
private parentLogger: Logger | null = null;
private store: boolean;
private syslogClient: any | null = null;
private syslogClient: SyslogPro.RFC5424 | null = null;
constructor(domain: string, color?: string, store = true) {
constructor(domain: string, color?: KeywordColor, store = true) {
this.domain = {
name: domain,
color,
@ -28,9 +29,9 @@ export default class Logger {
if (config.syslog) {
this.syslogClient = new SyslogPro.RFC5424({
applacationName: 'FoundKey',
applicationName: 'FoundKey',
timestamp: true,
encludeStructuredData: true,
includeStructuredData: true,
color: true,
extendedColor: true,
server: {
@ -41,7 +42,7 @@ export default class Logger {
}
}
public createSubLogger(domain: string, color?: string, store = true): Logger {
public createSubLogger(domain: string, color?: KeywordColor, store = true): Logger {
const logger = new Logger(domain, color, store);
logger.parentLogger = this;
return logger;
@ -57,7 +58,7 @@ export default class Logger {
}
const time = dateFormat(new Date(), 'HH:mm:ss');
const worker = cluster.isPrimary ? '*' : cluster.worker.id;
const worker = cluster.isPrimary ? '*' : cluster.worker!.id;
const l =
level === 'error' ? important ? chalk.bgRed.white('ERR ') : chalk.red('ERR ') :
level === 'warning' ? chalk.yellow('WARN') :
@ -85,7 +86,6 @@ export default class Logger {
level === 'error' ? this.syslogClient.error :
level === 'warning' ? this.syslogClient.warning :
level === 'success' ? this.syslogClient.info :
level === 'debug' ? this.syslogClient.info :
level === 'info' ? this.syslogClient.info :
null as never;
@ -94,7 +94,7 @@ export default class Logger {
}
}
public error(x: string | Error, data?: Record<string, any> = {}, important = false): void { // 実行を継続できない状況で使う
public error(x: string | Error, data: Record<string, any> = {}, important = false): void { // 実行を継続できない状況で使う
if (x instanceof Error) {
data.e = x;
this.log('error', x.toString(), data, important);

View File

@ -23,7 +23,7 @@ export async function createMessage(user: { id: User['id']; host: User['host'];
text: text ? text.trim() : null,
userId: user.id,
isRead: false,
reads: [] as any[],
reads: [] as string[],
uri,
} as MessagingMessage;

View File

@ -1,4 +1,4 @@
import { Brackets, In, IsNull, Not } from 'typeorm';
import { Brackets, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
import { publishNoteStream } from '@/services/stream.js';
import renderDelete from '@/remote/activitypub/renderer/delete.js';
import renderAnnounce from '@/remote/activitypub/renderer/announce.js';
@ -106,7 +106,7 @@ async function findCascadingNotes(note: Note): Promise<Note[]> {
}
async function getMentionedRemoteUsers(note: Note): Promise<IRemoteUser[]> {
const where = [] as any[];
const where: FindOptionsWhere<User>[] = [];
// mention / reply / dm
if (note.mentions.length > 0) {

View File

@ -15,10 +15,10 @@
"@rollup/pluginutils": "^4.2.1",
"@syuilo/aiscript": "0.11.1",
"@vitejs/plugin-vue": "^3.1.0",
"@vueuse/core": "9.1.0",
"abort-controller": "3.0.0",
"autobind-decorator": "2.4.0",
"autosize": "5.0.1",
"autwh": "0.1.0",
"blurhash": "1.1.5",
"broadcast-channel": "4.13.0",
"browser-image-resizer": "2.4.1",
@ -86,7 +86,6 @@
"@types/katex": "0.14.0",
"@types/matter-js": "0.17.7",
"@types/mocha": "9.1.1",
"@types/oauth": "0.9.1",
"@types/punycode": "2.1.0",
"@types/qrcode": "1.5.0",
"@types/seedrandom": "3.0.2",
@ -98,10 +97,10 @@
"@typescript-eslint/eslint-plugin": "^5.36.2",
"@typescript-eslint/parser": "^5.36.2",
"cross-env": "7.0.3",
"cypress": "10.3.0",
"eslint": "^8.20.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-vue": "^9.1.1",
"rollup-plugin-sizes": "^1.0.4",
"start-server-and-test": "1.14.0"
}
}

View File

@ -1,16 +1,16 @@
<template>
<div v-size="{ max: [450] }" class="wrpstxzv" :class="{ children: depth > 1 }">
<div v-size="{ max: [450] }" class="wrpstxzv" :class="{ children: depth > 1, supercompact }">
<div class="main">
<MkAvatar class="avatar" :user="note.user"/>
<div class="body">
<XNoteHeader class="header" :note="note" :mini="true"/>
<XNoteHeader :supercompact="supercompact" class="header" :note="note" :mini="true"/>
<div class="body">
<p v-if="note.cw != null" class="cw">
<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
<XCwButton v-model="showContent" :note="note"/>
</p>
<div v-show="note.cw == null || showContent" class="content">
<MkNoteSubNoteContent class="text" :note="note"/>
<MkNoteSubNoteContent class="text" :note="note" :supercompact="supercompact && note.cw == null" :compact-heads="compactHeads"/>
</div>
</div>
</div>
@ -38,11 +38,15 @@ const props = withDefaults(defineProps<{
note: foundkey.entities.Note;
conversation?: foundkey.entities.Note[] | null;
supercompact?: boolean;
compactHeads?: boolean;
// how many notes are in between this one and the note being viewed in detail
depth?: number;
}>(), {
conversation: null,
depth: 1,
supercompact: false,
compactHeads: false,
});
let showContent = $ref(false);
@ -67,6 +71,13 @@ const replies: foundkey.entities.Note[] = props.conversation?.filter(item => ite
}
}
&.supercompact {
> .main > .avatar {
width: 30px;
height: 30px;
}
}
> .main {
display: flex;
@ -76,7 +87,7 @@ const replies: foundkey.entities.Note[] = props.conversation?.filter(item => ite
margin: 0 8px 0 0;
width: 38px;
height: 38px;
border-radius: 8px;
border-radius: 50%;
}
> .body {

View File

@ -0,0 +1,28 @@
<template>
<MkTooltip :showing="true" :target-element="targetElement" :max-width="400" @closed="emit('closed')">
<div class="tooltip-note">
<MkNoteSub v-for="note in notes" :key="note.id" :note="note"/>
</div>
</MkTooltip>
</template>
<script lang="ts" setup>
import * as misskey from 'foundkey-js';
import MkTooltip from './ui/tooltip.vue';
import MkNoteSub from './MkNoteSub.vue';
defineProps<{
notes: misskey.entities.Note[];
targetElement: HTMLElement;
}>();
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
</script>
<style lang="scss" scoped>
.tooltip-note {
text-align: left;
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<div ref="rootEl" class="swhvrteh _popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}">
<div ref="rootEl" class="swhvrteh _popup _shadow" :style="{ zIndex }">
<ol v-if="type === 'user'" ref="suggests" class="users">
<li v-for="user in users" tabindex="-1" class="user" @click="complete(type, user)" @keydown="onKeydown">
<img class="avatar" :src="user.avatarUrl"/>

View File

@ -5,7 +5,6 @@
draggable="true"
:title="title"
@click="onClick"
@contextmenu.stop="onContextmenu"
@dragstart="onDragstart"
@dragend="onDragend"
>
@ -101,10 +100,6 @@ function onClick(ev: MouseEvent): void {
}
}
function onContextmenu(ev: MouseEvent): void {
os.contextMenu(getMenu(), ev);
}
function onDragstart(ev: DragEvent): void {
if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = 'move';

View File

@ -5,7 +5,6 @@
draggable="true"
:title="title"
@click="onClick"
@contextmenu.stop="onContextmenu"
@mouseover="onMouseover"
@mouseout="onMouseout"
@dragover.prevent.stop="onDragover"
@ -218,27 +217,6 @@ function deleteFolder() {
});
}
function onContextmenu(ev: MouseEvent) {
os.contextMenu([{
text: i18n.ts.openInWindow,
icon: 'fas fa-window-restore',
action: () => {
os.popup(defineAsyncComponent(() => import('./drive-window.vue')), {
initialFolder: props.folder,
}, {
}, 'closed');
},
}, null, {
text: i18n.ts.rename,
icon: 'fas fa-i-cursor',
action: rename,
}, null, {
text: i18n.ts.delete,
icon: 'fas fa-trash-alt',
danger: true,
action: deleteFolder,
}], ev);
}
</script>
<style lang="scss" scoped>

View File

@ -1,7 +1,7 @@
<template>
<div class="yfudmmck">
<nav>
<div class="path" @contextmenu.prevent.stop="() => {}">
<div class="path">
<XNavFolder
:class="{ current: folder == null }"
:parent-folder="folder"
@ -33,7 +33,6 @@
@dragenter="onDragenter"
@dragleave="onDragleave"
@drop.prevent.stop="onDrop"
@contextmenu.stop="onContextmenu"
>
<div ref="contents" class="contents">
<div v-show="folders.length > 0" ref="foldersContainer" class="folders">
@ -601,10 +600,6 @@ function showMenu(ev: MouseEvent) {
os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
}
function onContextmenu(ev: MouseEvent) {
os.contextMenu(getMenu(), ev);
}
onMounted(() => {
if (defaultStore.state.enableInfiniteScroll && loadMoreFiles.value) {
nextTick(() => {

View File

@ -1,5 +1,5 @@
<template>
<a :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu">
<a :href="to" :class="active ? activeClass : null" @click.prevent="nav">
<slot></slot>
</a>
</template>
@ -32,39 +32,6 @@ const active = $computed(() => {
return resolved.route.name === router.currentRoute.value.name;
});
function onContextmenu(ev) {
const selection = window.getSelection();
if (selection && selection.toString() !== '') return;
os.contextMenu([{
type: 'label',
text: props.to,
}, {
icon: 'fas fa-window-maximize',
text: i18n.ts.openInWindow,
action: () => {
os.pageWindow(props.to);
},
}, {
icon: 'fas fa-expand-alt',
text: i18n.ts.showInPage,
action: () => {
router.push(props.to);
},
}, null, {
icon: 'fas fa-external-link-alt',
text: i18n.ts.openInNewTab,
action: () => {
window.open(props.to, '_blank');
},
}, {
icon: 'fas fa-link',
text: i18n.ts.copyLink,
action: () => {
copyToClipboard(`${url}${props.to}`);
},
}], ev);
}
function nav() {
if (props.behavior === 'browser') {
location.href = props.to;

View File

@ -1,9 +1,9 @@
<template>
<span v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: defaultStore.state.squareAvatars }" :style="{ color }" :title="acct(user)" @click="onClick">
<span v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect handleHash" :class="{ cat: user.isCat, square: defaultStore.state.squareAvatars }" :style="{ color, background: handleHash }" :title="acct(user)" @click="onClick">
<img class="inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
</span>
<MkA v-else v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: defaultStore.state.squareAvatars }" :style="{ color }" :to="userPage(user)" :title="acct(user)" :target="target">
<MkA v-else v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect handleHash" :class="{ cat: user.isCat, square: defaultStore.state.squareAvatars }" :style="{ color, background: handleHash }" :to="userPage(user)" :title="acct(user)" :target="target">
<img class="inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="showIndicator" class="indicator" :user="user"/>
</MkA>
@ -39,6 +39,67 @@ const url = $computed(() => defaultStore.state.disableShowingAnimatedImages
? getStaticImageUrl(props.user.avatarUrl)
: props.user.avatarUrl);
// colors from Nature Methods column by Bang Wong
// https://www.nature.com/articles/nmeth.1618
const hashlette = ['rgb(0, 0, 0)', 'rgb(230, 159, 0)', 'rgb(86, 180, 233)', 'rgb(0, 158, 115)', 'rgb(240, 228, 66)', 'rgb(0, 114, 178)', 'rgb(213, 94, 0)', 'rgb(204, 121, 167)'] as const;
/**
* FNV-1a Hash implementation (32, 64, 128, 256, 512, and 1024 bit)
*
* stolen under the terms of MIT license which i actually had to make up myself cause all i had was the readme
*
* Copyright © 2017 Travis Webb and desudesutalk
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the Software), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
* @author Travis Webb <me@traviswebb.com>
* @see http://tools.ietf.org/html/draft-eastlake-fnv-06
*/
function fnv1a32(str: string): number {
let i: number, l = str.length - 3, t0 = 0, v0 = 0x9dc5, t1 = 0, v1 = 0x811c;
for (i = 0; i < l;) {
v0 ^= str.charCodeAt(i++);
t0 = v0 * 403; t1 = v1 * 403;
t1 += v0 << 8;
v1 = (t1 + (t0 >>> 16)) & 65535; v0 = t0 & 65535;
v0 ^= str.charCodeAt(i++);
t0 = v0 * 403; t1 = v1 * 403;
t1 += v0 << 8;
v1 = (t1 + (t0 >>> 16)) & 65535; v0 = t0 & 65535;
v0 ^= str.charCodeAt(i++);
t0 = v0 * 403; t1 = v1 * 403;
t1 += v0 << 8;
v1 = (t1 + (t0 >>> 16)) & 65535; v0 = t0 & 65535;
v0 ^= str.charCodeAt(i++);
t0 = v0 * 403; t1 = v1 * 403;
t1 += v0 << 8;
v1 = (t1 + (t0 >>> 16)) & 65535; v0 = t0 & 65535;
}
while (i < l + 3) {
v0 ^= str.charCodeAt(i++);
t0 = v0 * 403; t1 = v1 * 403;
t1 += v0 << 8;
v1 = (t1 + (t0 >>> 16)) & 65535; v0 = t0 & 65535;
}
return ((v1 << 16) >>> 0) + v0;
}
const handleHash = $computed(() => {
const stripColours = fnv1a32(acct(props.user)).toString(8).split('').map(digitStr => hashlette[parseInt(digitStr)]);
const fraction = 1 / stripColours.length;
const positions = stripColours.flatMap((colour, idx) =>
[`${colour} ${fraction * idx}turn`, `${colour} ${fraction * (idx + 1)}turn`]
);
return `conic-gradient(${positions})`;
});
function onClick(ev: MouseEvent) {
emit('click', ev);
}
@ -91,6 +152,14 @@ watch(() => props.user.avatarBlurhash, () => {
height: 100%;
}
&.handleHash {
> .inner {
padding: 10%;
width: 80%;
height: 80%;
}
}
> .indicator {
position: absolute;
z-index: 1;

View File

@ -1,5 +1,5 @@
<template>
<MfmCore :text="text" :plain="plain" :nowrap="nowrap" :author="author" :custom-emojis="customEmojis" :is-note="isNote" class="havbbuyv" :class="{ nowrap }"/>
<MfmCore :text="text" :plain="plain" :nowrap="nowrap" :author="author" :custom-emojis="customEmojis" :is-note="isNote" class="havbbuyv" :class="{ nowrap }" :compact-heads="compactHeads"/>
</template>
<script lang="ts" setup>
@ -12,6 +12,7 @@ withDefaults(defineProps<{
author?: any;
customEmojis?: any;
isNote?: boolean;
compactHeads?: boolean;
}>(), {
plain: false,
nowrap: false,

View File

@ -1,7 +1,6 @@
<template>
<component
:is="self ? 'MkA' : 'a'" ref="el" class="ieqqeuvs _link" :[attr]="self ? url.slice(local.length) : url" :rel="rel" :target="target"
@contextmenu.stop="() => {}"
>
<template v-if="!self">
<span class="schema">{{ schema }}//</span>

View File

@ -4,14 +4,12 @@
<div class="main">
<template v-for="item in items">
<button v-if="item.action" class="_button" @click="$event => { item.action($event); close(); }">
<i class="icon" :class="item.icon"></i>
<i class="icon" :class="[ item.icon, { indicated: item.indicated } ]"></i>
<div class="text">{{ item.text }}</div>
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
</button>
<MkA v-else :to="item.to" @click.passive="close()">
<i class="icon" :class="item.icon"></i>
<i class="icon" :class="[ item.icon, { indicated: item.indicated } ]"></i>
<div class="text">{{ item.text }}</div>
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
</MkA>
</template>
</div>
@ -66,7 +64,7 @@ const items = Object.keys(menuDef).filter(k => !menu.includes(k)).map(k => menuD
icon: def.icon,
to: def.to,
action: def.action,
indicate: def.indicated,
indicated: def.indicated,
}));
function close() {
@ -146,20 +144,6 @@ function help(ev: MouseEvent) {
font-size: 0.8em;
line-height: 1.5em;
}
> .indicator {
position: absolute;
top: 32px;
left: 32px;
color: var(--indicator);
font-size: 8px;
animation: blink 1s infinite;
@media (max-width: 500px) {
top: 16px;
left: 16px;
}
}
}
}

View File

@ -12,7 +12,6 @@
:alt="video.comment"
preload="none"
controls
@contextmenu.stop
>
<source
:src="video.url"

View File

@ -1,7 +1,7 @@
<template>
<MkA v-if="url.startsWith('/')" v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :style="{ background: bgCss }">
<img :class="$style.icon" :src="`/avatar/@${username}@${host}`" alt="">
<span class="main">
<MkA v-if="url.startsWith('/')" v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe, [$style.compact]: compact }]" :to="url" :style="{ background: bgCss }">
<img :class="[$style.icon, { [$style.compact]: compact }]" :src="`/avatar/@${username}@${host}`" alt="">
<span v-if="!compact" class="main">
<span class="username">@{{ username }}</span>
<span v-if="(host != localHost) || defaultStore.state.showFullAcct" :class="$style.mainHost">@{{ toUnicode(host) }}</span>
</span>
@ -25,6 +25,7 @@ import { defaultStore } from '@/store';
const props = defineProps<{
username: string;
host: string;
compact?: boolean;
}>();
const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`;
@ -49,6 +50,10 @@ useCssModule();
border-radius: 999px;
color: var(--mention);
&.compact {
padding: 0;
}
&.isMe {
color: var(--mentionMe);
}
@ -61,6 +66,14 @@ useCssModule();
margin: 0 0.2em 0 0;
vertical-align: bottom;
border-radius: 100%;
.compact > & {
margin: 0;
border: 0.1em solid var(--mention);
}
.compact.isMe > & {
border: 0.1em solid var(--mentionMe);
}
}
.mainHost {

View File

@ -42,6 +42,11 @@ export default defineComponent({
type: Boolean,
default: true,
},
compactHeads: {
required: false,
type: Boolean,
default: false,
}
},
render() {
@ -232,6 +237,7 @@ export default defineComponent({
key: Math.random(),
host: (token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host) || host,
username: token.props.username,
compact: this.compactHeads,
})];
}

View File

@ -1,7 +1,7 @@
<template>
<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')">
<div ref="rootEl" class="hrmcaedk _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }">
<div class="header" @contextmenu="onContextmenu">
<div class="header">
<button v-if="history.length > 0" v-tooltip="i18n.ts.goBack" class="_button" @click="back()"><i class="fas fa-arrow-left"></i></button>
<span v-else style="display: inline-block; width: 20px"></span>
<span v-if="pageMetadata?.value" class="title">
@ -59,33 +59,6 @@ provide('shouldOmitHeaderTitle', true);
provide('shouldHeaderThin', true);
const pageUrl = $computed(() => url + path);
const contextmenu = $computed(() => {
return [{
type: 'label',
text: path,
}, {
icon: 'fas fa-expand-alt',
text: i18n.ts.showInPage,
action: expand,
}, {
icon: 'fas fa-external-link-alt',
text: i18n.ts.popout,
action: popout,
}, null, {
icon: 'fas fa-external-link-alt',
text: i18n.ts.openInNewTab,
action: () => {
window.open(pageUrl, '_blank');
modal.close();
},
}, {
icon: 'fas fa-link',
text: i18n.ts.copyLink,
action: () => {
copyToClipboard(pageUrl);
},
}];
});
function navigate(path, record = true) {
if (record) history.push(router.getCurrentPath());
@ -105,10 +78,6 @@ function popout() {
_popout(path, rootEl);
modal.close();
}
function onContextmenu(ev: MouseEvent) {
os.contextMenu(contextmenu, ev);
}
</script>
<style lang="scss" scoped>

View File

@ -29,7 +29,7 @@
<MkVisibility :note="note"/>
</div>
</div>
<article class="article" @contextmenu.stop="onContextmenu">
<article class="article">
<header class="header">
<MkAvatar class="avatar" :user="appearNote.user" :show-indicator="true"/>
<div class="body">
@ -230,24 +230,6 @@ function undoReact(note): void {
});
}
function onContextmenu(ev: MouseEvent): void {
const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true;
if (el.parentElement) {
return isLink(el.parentElement);
}
};
if (isLink(ev.target)) return;
if (window.getSelection().toString() !== '') return;
if (defaultStore.state.useReactionPickerForContextMenu) {
ev.preventDefault();
react();
} else {
os.contextMenu(getNoteMenu({ note, translating, translation, menuButton, isDeleted }), ev).then(focus);
}
}
function menu(viaKeyboard = false): void {
os.popupMenu(getNoteMenu({ note, translating, translation, menuButton, isDeleted }), menuButton.value, {
viaKeyboard,

View File

@ -1,10 +1,11 @@
<template>
<header class="kkwtjztg">
<MkA v-user-preview="note.user.id" class="name" :to="userPage(note.user)">
<MkUserName :user="note.user"/>
<MkUserName v-if="!supercompact" :user="note.user"/>
<div v-if="supercompact" class="username"><MkAcct :user="note.user"/></div>
</MkA>
<div v-if="note.user.isBot" class="is-bot">bot</div>
<div class="username"><MkAcct :user="note.user"/></div>
<div v-if="!supercompact" class="username"><MkAcct :user="note.user"/></div>
<div class="info">
<MkA class="created-at" :to="notePage(note)">
<MkTime :time="note.createdAt"/>
@ -23,6 +24,7 @@ import { userPage } from '@/filters/user';
defineProps<{
note: foundkey.entities.Note;
pinned?: boolean;
supercompact?: boolean;
}>();
</script>

View File

@ -9,7 +9,7 @@
:tabindex="!isDeleted ? '-1' : null"
:class="{ renote: isRenote }"
>
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" :supercompact="compactParent" :compact-heads="compactHeads" class="reply-to"/>
<div v-if="pinned" class="info"><i class="fas fa-thumbtack"></i> {{ i18n.ts.pinnedNote }}</div>
<div v-if="appearNote._featuredId_" class="info"><i class="fas fa-bolt"></i> {{ i18n.ts.featured }}</div>
<div v-if="isRenote" class="renote">
@ -30,7 +30,7 @@
<MkVisibility :note="note"/>
</div>
</div>
<article class="article" @contextmenu.stop="onContextmenu">
<article class="article">
<MkAvatar class="avatar" :user="appearNote.user"/>
<div class="main">
<XNoteHeader class="header" :note="appearNote" :mini="true"/>
@ -42,8 +42,8 @@
</p>
<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed, isLong }">
<div class="text">
<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i class="fas fa-reply"></i></MkA>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
<MkA v-if="appearNote.replyId" class="reply" :to="`/notes/${appearNote.replyId}`"><i ref="parentReply" class="fas fa-reply"></i></MkA>
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis" :compact-heads="compactHeads"/>
<a v-if="appearNote.renote != null" class="rp">RN:</a>
<div v-if="translating || translation" class="translation">
<MkLoading v-if="translating" mini/>
@ -70,7 +70,7 @@
</div>
<footer class="footer">
<XReactionsViewer ref="reactionsViewer" :note="appearNote"/>
<button class="button _button" @click="reply()">
<button ref="replyButton" class="button _button" @click="reply()">
<template v-if="appearNote.reply"><i class="fas fa-reply-all"></i></template>
<template v-else><i class="fas fa-reply"></i></template>
<p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p>
@ -107,6 +107,7 @@ import * as foundkey from 'foundkey-js';
import MkNoteSub from './MkNoteSub.vue';
import XNoteHeader from './note-header.vue';
import XNoteSimple from './note-simple.vue';
import NoteTooltip from './NoteTooltip.vue';
import XReactionsViewer from './reactions-viewer.vue';
import XMediaList from './media-list.vue';
import XCwButton from './cw-button.vue';
@ -127,10 +128,13 @@ import { $i } from '@/account';
import { i18n } from '@/i18n';
import { getNoteMenu } from '@/scripts/get-note-menu';
import { useNoteCapture } from '@/scripts/use-note-capture';
import { useTooltip } from '@/scripts/use-tooltip';
const props = defineProps<{
note: foundkey.entities.Note;
pinned?: boolean;
compactParent?: boolean;
compactHeads?: boolean;
}>();
const inChannel = inject('inChannel', null);
@ -157,9 +161,11 @@ const isRenote = (
const el = ref<HTMLElement>();
const menuButton = ref<HTMLElement>();
const replyButton = ref<HTMLElement>();
const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
const renoteTime = ref<HTMLElement>();
const reactButton = ref<HTMLElement>();
const parentReply = ref<HTMLElement>();
let appearNote = $computed(() => isRenote ? note.renote as foundkey.entities.Note : note);
const isMyRenote = $i && ($i.id === note.userId);
const showContent = ref(false);
@ -192,6 +198,30 @@ useNoteCapture({
isDeletedRef: isDeleted,
});
useTooltip(parentReply, async (showing) => {
const parentNote = await os.api('notes/show', {
noteId: appearNote.replyId,
});
os.popup(NoteTooltip, {
showing,
notes: [parentNote],
targetElement: parentReply.value,
}, {}, 'closed');
});
useTooltip(replyButton, async (showing) => {
const replies = await os.api('notes/replies', {
noteId: appearNote.id,
});
os.popup(NoteTooltip, {
showing,
notes: replies,
targetElement: replyButton.value,
}, {}, 'closed');
});
function reply(viaKeyboard = false): void {
pleaseLogin();
os.post({
@ -224,24 +254,6 @@ function undoReact(): void {
const currentClipPage = inject<Ref<foundkey.entities.Clip> | null>('currentClipPage', null);
function onContextmenu(ev: MouseEvent): void {
const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true;
if (el.parentElement) {
return isLink(el.parentElement);
}
};
if (isLink(ev.target)) return;
if (window.getSelection().toString() !== '') return;
if (defaultStore.state.useReactionPickerForContextMenu) {
ev.preventDefault();
react();
} else {
os.contextMenu(getNoteMenu({ note, translating, translation, menuButton, isDeleted, currentClipPage }), ev).then(focus);
}
}
function menu(viaKeyboard = false): void {
os.popupMenu(getNoteMenu({ note, translating, translation, menuButton, isDeleted, currentClipPage }), menuButton.value, {
viaKeyboard,

View File

@ -10,7 +10,7 @@
<template #default="{ items: notes }">
<div class="giivymft" :class="{ noGap }">
<XList ref="notes" v-slot="{ item: note }" :items="notes" :direction="pagination.reversed ? 'up' : 'down'" :reversed="pagination.reversed" :no-gap="noGap" class="notes">
<XNote :key="note._featuredId_ || note.id" class="qtqtichx" :note="note"/>
<XNote :key="note._featuredId_ || note.id" class="qtqtichx" :note="note" :compact-parent="true"/>
</XList>
</div>
</template>

View File

@ -9,7 +9,7 @@
<template #default="{ items: notifications }">
<XList v-slot="{ item: notification }" class="elsfgstc" :items="notifications" :no-gap="true">
<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" :compact-parent="true" :compact-heads="true" />
<XNotification v-else :key="notification.id" :notification="notification" :with-time="true" :full="true" class="_panel notification"/>
</XList>
</template>

View File

@ -7,7 +7,6 @@
:close-button="true"
:buttons-left="buttonsLeft"
:buttons-right="buttonsRight"
:contextmenu="contextmenu"
@closed="$emit('closed')"
>
<template #header>
@ -84,28 +83,6 @@ provideMetadataReceiver((info) => {
provide('shouldOmitHeaderTitle', true);
provide('shouldHeaderThin', true);
const contextmenu = $computed(() => ([{
icon: 'fas fa-expand-alt',
text: i18n.ts.showInPage,
action: expand,
}, {
icon: 'fas fa-external-link-alt',
text: i18n.ts.popout,
action: popout,
}, {
icon: 'fas fa-external-link-alt',
text: i18n.ts.openInNewTab,
action: () => {
window.open(url + router.getCurrentPath(), '_blank');
windowEl.close();
},
}, {
icon: 'fas fa-link',
text: i18n.ts.copyLink,
action: () => {
copyToClipboard(url + router.getCurrentPath());
},
}]));
function back() {
history.pop();

View File

@ -2,7 +2,7 @@
<div v-show="files.length != 0" class="skeikyzd">
<XDraggable v-model="_files" class="files" item-key="id" animation="150" delay="100" delay-on-touch-only="true">
<template #item="{element}">
<div @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)">
<div @click="showFileMenu(element, $event)">
<MkDriveFileThumbnail :data-id="element.id" class="thumbnail" :file="element" fit="cover"/>
<div v-if="element.isSensitive" class="sensitive">
<i class="fas fa-exclamation-triangle icon"></i>

View File

@ -12,11 +12,11 @@
</button>
<div>
<span class="text-count" :class="{ over: textLength > maxTextLength }">{{ maxTextLength - textLength }}</span>
<span v-if="localOnly" class="local-only"><i class="fas fa-biohazard"></i></span>
<span v-if="localOnly" class="local-only"><i class="fas fa-tree-city"></i></span>
<button ref="visibilityButton" v-tooltip="i18n.ts.visibility" class="_button visibility" :disabled="channel != null" @click="setVisibility">
<span v-if="visibility === 'public'"><i class="fas fa-globe"></i></span>
<span v-else-if="visibility === 'home'"><i class="fas fa-home"></i></span>
<span v-else-if="visibility === 'followers'"><i class="fas fa-unlock"></i></span>
<span v-if="visibility === 'public'"><i class="fas fa-bullhorn"></i></span>
<span v-else-if="visibility === 'home'"><i class="fas fa-lock-open"></i></span>
<span v-else-if="visibility === 'followers'"><i class="fas fa-lock"></i></span>
<span v-else-if="visibility === 'specified'"><i class="fas fa-envelope"></i></span>
</button>
<button v-tooltip="i18n.ts.previewNoteText" class="_button preview" :class="{ active: showPreview }" @click="showPreview = !showPreview"><i class="fas fa-file-code"></i></button>

View File

@ -8,7 +8,10 @@
@click="toggleReaction()"
>
<MkEmoji class="icon" :emoji="reaction" :custom-emojis="note.emojis" :is-reaction="true" :normal="true"/>
<span class="count">{{ count }}</span>
<span v-if="users === undefined" class="count">{{ count }}</span>
<span v-if="users !== undefined">
<MkAvatar v-for="u in users" class="user" :style="{ height: '2em', width: '2em' }" :key="u.id" :user="u" />
</span>
</button>
</template>
@ -25,13 +28,14 @@ const props = defineProps<{
count: number;
isInitial: boolean;
note: foundkey.entities.Note;
users?: foundkey.entities.UserLite[];
}>();
const buttonRef = ref<HTMLElement>();
const canToggle = computed(() => !props.reaction.match(/@\w/) && $i);
const toggleReaction = () => {
const toggleReaction = (): void => {
if (!canToggle.value) return;
const oldReaction = props.note.myReaction;

View File

@ -1,14 +1,16 @@
<template>
<div class="tdflqwzn" :class="{ isMe }">
<XReaction v-for="(count, reaction) in note.reactions" :key="reaction" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :note="note"/>
<div ref="targetEl" class="tdflqwzn" :class="{ isMe }">
<XReaction v-for="(count, reaction) in note.reactions" :key="reaction" :reaction="reaction" :count="count" :is-initial="initialReactions.has(reaction)" :users="usersMap[reaction]" :note="note"/>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useIntersectionObserver } from '@vueuse/core';
import * as foundkey from 'foundkey-js';
import XReaction from './reactions-viewer.reaction.vue';
import { $i } from '@/account';
import * as os from '@/os';
const props = defineProps<{
note: foundkey.entities.Note;
@ -17,6 +19,39 @@ const props = defineProps<{
const initialReactions = new Set(Object.keys(props.note.reactions));
const isMe = computed(() => $i && $i.id === props.note.userId);
const usersMap = ref({});
const superloaded = ref(false);
const targetEl = ref();
async function updateReactions(currentValue): Promise<void> {
const reactions = await os.api('notes/reactions', {
noteId: currentValue.id,
limit: 100,
});
const users = {};
for (const reaction of reactions) {
if (users[reaction.type] === undefined) {
users[reaction.type] = [];
}
users[reaction.type].push(reaction.user);
}
usersMap.value = users;
superloaded.value = true;
}
async function prepareFetch(note: foundkey.entities.Note): Promise<void> {
superloaded.value = false;
const { stop } = useIntersectionObserver(targetEl, ([{ isIntersecting }]) => {
if (isIntersecting) {
superloaded.value = true;
stop();
updateReactions(note);
}
});
}
onMounted(() => prepareFetch(props.note));
watch(props.note, prepareFetch, { deep: true });
</script>
<style lang="scss" scoped>

View File

@ -40,11 +40,6 @@
</div>
</div>
</div>
<div class="social _section">
<a v-if="meta && meta.enableTwitterIntegration" class="_borderButton _gap" :href="`${apiUrl}/signin/twitter`"><i class="fab fa-twitter" style="margin-right: 4px;"></i>{{ i18n.t('signinWith', { x: 'Twitter' }) }}</a>
<a v-if="meta && meta.enableGithubIntegration" class="_borderButton _gap" :href="`${apiUrl}/signin/github`"><i class="fab fa-github" style="margin-right: 4px;"></i>{{ i18n.t('signinWith', { x: 'GitHub' }) }}</a>
<a v-if="meta && meta.enableDiscordIntegration" class="_borderButton _gap" :href="`${apiUrl}/signin/discord`"><i class="fab fa-discord" style="margin-right: 4px;"></i>{{ i18n.t('signinWith', { x: 'Discord' }) }}</a>
</div>
</form>
</template>

View File

@ -1,9 +1,9 @@
<template>
<div class="wrmlmaau" :class="{ collapsed, isLong }">
<div class="wrmlmaau" :class="{ collapsed, isLong, supercompact }">
<div class="body">
<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span>
<MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="fas fa-reply"></i></MkA>
<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
<MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i ref="parentReply" class="fas fa-reply"></i></MkA>
<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis" :compact-heads="compactHeads"/>
<MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
</div>
<details v-if="note.files.length > 0">
@ -15,7 +15,7 @@
<XPoll :note="note"/>
</details>
<button v-if="isLong && collapsed" class="fade _button" @click="collapsed = false">
<span>{{ i18n.ts.showMore }}</span>
<span v-if="!supercompact">{{ i18n.ts.showMore }}</span>
</button>
<button v-if="isLong && !collapsed" class="showLess _button" @click="collapsed = true">
<span>{{ i18n.ts.showLess }}</span>
@ -24,13 +24,19 @@
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import * as foundkey from 'foundkey-js';
import XPoll from './poll.vue';
import XMediaList from './media-list.vue';
import NoteTooltip from './NoteTooltip.vue';
import { i18n } from '@/i18n';
import * as os from '@/os';
import { useTooltip } from '@/scripts/use-tooltip';
const props = defineProps<{
note: foundkey.entities.Note;
supercompact?: boolean;
compactHeads?: boolean;
}>();
const isLong = (
@ -39,7 +45,20 @@ const isLong = (
(props.note.text.length > 500)
)
);
const collapsed = $ref(props.note.cw == null && isLong);
const collapsed = $ref(props.supercompact || (props.note.cw == null && isLong));
const parentReply = ref<HTMLElement>();
useTooltip(parentReply, async (showing): Promise<void> => {
const parentNote = await os.api('notes/show', {
noteId: props.note.replyId,
});
os.popup(NoteTooltip, {
showing,
notes: [parentNote],
targetElement: parentReply.value,
}, {}, 'closed');
});
</script>
<style lang="scss" scoped>
@ -64,6 +83,10 @@ const collapsed = $ref(props.note.cw == null && isLong);
max-height: 9em;
overflow: hidden;
&.supercompact {
max-height: 4.5em;
}
> .fade {
display: block;
position: absolute;

View File

@ -1,85 +0,0 @@
<template>
<transition :name="$store.state.animation ? 'fade' : ''" appear>
<div ref="rootEl" class="nvlagfpb" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
<MkMenu :items="items" class="_popup _shadow" :align="'left'" @close="$emit('closed')"/>
</div>
</transition>
</template>
<script lang="ts" setup>
import { onMounted, onBeforeUnmount } from 'vue';
import MkMenu from './menu.vue';
import { MenuItem } from './types/menu.vue';
import contains from '@/scripts/contains';
import * as os from '@/os';
const props = defineProps<{
items: MenuItem[];
ev: MouseEvent;
}>();
const emit = defineEmits<{
(ev: 'closed'): void;
}>();
let rootEl = $ref<HTMLDivElement>();
let zIndex = $ref<number>(os.claimZIndex('high'));
onMounted(() => {
let left = props.ev.pageX + 1; // + 1
let top = props.ev.pageY + 1; // + 1
const width = rootEl.offsetWidth;
const height = rootEl.offsetHeight;
if (left + width - window.pageXOffset > window.innerWidth) {
left = window.innerWidth - width + window.pageXOffset;
}
if (top + height - window.pageYOffset > window.innerHeight) {
top = window.innerHeight - height + window.pageYOffset;
}
if (top < 0) {
top = 0;
}
if (left < 0) {
left = 0;
}
rootEl.style.top = `${top}px`;
rootEl.style.left = `${left}px`;
for (const el of Array.from(document.querySelectorAll('body *'))) {
el.addEventListener('mousedown', onMousedown);
}
});
onBeforeUnmount(() => {
for (const el of Array.from(document.querySelectorAll('body *'))) {
el.removeEventListener('mousedown', onMousedown);
}
});
function onMousedown(evt: Event) {
if (!contains(rootEl, evt.target) && (rootEl !== evt.target)) emit('closed');
}
</script>
<style lang="scss" scoped>
.nvlagfpb {
position: absolute;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
transform-origin: left top;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
transform: scale(0.9);
}
</style>

View File

@ -4,7 +4,6 @@
class="rrevdjwt"
: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>
@ -15,28 +14,24 @@
<span><MkEllipsis/></span>
</span>
<MkA v-else-if="item.type === 'link'" :to="item.to" :tabindex="i" class="_button item" @click.passive="close()">
<i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
<i v-if="item.icon" class="fa-fw" :class="[ item.icon, { indicated: item.indicate }]"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
<span>{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></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()">
<i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
<i v-if="item.icon" class="fa-fw" :class="[ item.icon, { indicated: item.indicate }]"></i>
<span>{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
</a>
<button v-else-if="item.type === 'user'" :tabindex="i" class="_button item" :class="{ active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)">
<button v-else-if="item.type === 'user'" :tabindex="i" class="_button item" :class="{ active: item.active, indicated: item.indicate }" :disabled="item.active" @click="clicked(item.action, $event)">
<MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/>
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
</button>
<span v-else-if="item.type === 'switch'" :tabindex="i" class="item">
<FormSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</FormSwitch>
</span>
<button v-else :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)">
<i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
<i v-if="item.icon" class="fa-fw" :class="[ item.icon, { indicated: item.indicate }]"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
<span>{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
</button>
</template>
<span v-if="items2.length === 0" class="none item">
@ -236,15 +231,6 @@ function focusDown() {
width: 20px;
height: 20px;
}
> .indicator {
position: absolute;
top: 5px;
left: 13px;
color: var(--indicator);
font-size: 12px;
animation: blink 1s infinite;
}
}
> .divider {

View File

@ -1,7 +1,7 @@
<template>
<transition :name="$store.state.animation ? (type === 'drawer') ? 'modal-drawer' : (type === 'popup') ? 'modal-popup' : 'modal' : ''" :duration="$store.state.animation ? 200 : 0" appear @after-leave="emit('closed')" @enter="emit('opening')" @after-enter="onOpened">
<div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" class="qzhlnise" :class="{ drawer: type === 'drawer', dialog: type === 'dialog' || type === 'dialog:top', popup: type === 'popup' }" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
<div class="bg _modalBg" :class="{ transparent: transparentBg && (type === 'popup') }" :style="{ zIndex }" @click="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
<div class="bg _modalBg" :class="{ transparent: transparentBg && (type === 'popup') }" :style="{ zIndex }" @click="onBgClick"></div>
<div ref="content" class="content" :class="{ fixed, top: type === 'dialog:top' }" :style="{ zIndex }" @click.self="onBgClick">
<slot :max-height="maxHeight" :type="type"></slot>
</div>

View File

@ -2,7 +2,7 @@
<transition :name="$store.state.animation ? 'window' : ''" appear @after-leave="emit('closed')">
<div v-if="showing" ref="main" class="ebkgocck">
<div class="body _shadow _narrow_" @mousedown="moveToTop" @keydown="onKeydown">
<div class="header" :class="{ mini }" @contextmenu.prevent.stop="onContextmenu">
<div class="header" :class="{ mini }">
<span class="left">
<button v-for="button in buttonsLeft" v-tooltip="button.title" class="button _button" :class="{ highlighted: button.highlighted }" @click="button.onClick"><i :class="button.icon"></i></button>
</span>
@ -70,7 +70,6 @@ const props = withDefaults(defineProps<{
closeButton?: boolean;
mini?: boolean;
front?: boolean;
contextmenu?: MenuItem[];
buttonsLeft?: any[];
buttonsRight?: any[];
}>(), {
@ -79,7 +78,6 @@ const props = withDefaults(defineProps<{
closeButton: true,
mini: false,
front: false,
contextmenu: () => [] as MenuItem[],
buttonsLeft: () => [],
buttonsRight: () => [],
});
@ -121,12 +119,6 @@ function onKeydown(evt: KeyboardEvent): void {
}
}
function onContextmenu(ev: MouseEvent): void {
if (props.contextmenu) {
os.contextMenu(props.contextmenu, ev);
}
}
function moveToTop(): void {
main.style.zIndex = os.claimZIndex(props.front ? 'middle' : 'low').toString();
}
@ -139,9 +131,6 @@ function getClickPos(evt: MouseEvent | TouchEvent): [number, number] {
}
function onHeaderMousedown(evt: MouseEvent | TouchEvent): void {
// Right-click ignored as it is likely to have attempted to open a context menu
if (evt instanceof MouseEvent && evt.button === 2) return;
if (!contains(main, document.activeElement)) main.focus();
const position = main.getBoundingClientRect();

View File

@ -2,21 +2,21 @@
<MkModal ref="modal" :z-priority="'high'" :src="src" @click="modal.close()" @closed="emit('closed')">
<div class="gqyayizv _popup">
<button key="public" class="_button" :class="{ active: v === 'public' }" data-index="1" @click="choose('public')">
<div><i class="fas fa-globe"></i></div>
<div><i class="fas fa-bullhorn"></i></div>
<div>
<span>{{ i18n.ts._visibility.public }}</span>
<span>{{ i18n.ts._visibility.publicDescription }}</span>
</div>
</button>
<button key="home" class="_button" :class="{ active: v === 'home' }" data-index="2" @click="choose('home')">
<div><i class="fas fa-home"></i></div>
<div><i class="fas fa-lock-open"></i></div>
<div>
<span>{{ i18n.ts._visibility.home }}</span>
<span>{{ i18n.ts._visibility.homeDescription }}</span>
</div>
</button>
<button key="followers" class="_button" :class="{ active: v === 'followers' }" data-index="3" @click="choose('followers')">
<div><i class="fas fa-unlock"></i></div>
<div><i class="fas fa-lock"></i></div>
<div>
<span>{{ i18n.ts._visibility.followers }}</span>
<span>{{ i18n.ts._visibility.followersDescription }}</span>
@ -31,7 +31,7 @@
</button>
<div class="divider"></div>
<button key="localOnly" class="_button localOnly" :class="{ active: localOnly }" data-index="5" @click="localOnly = !localOnly">
<div><i class="fas fa-biohazard"></i></div>
<div><i class="fas fa-tree-city"></i></div>
<div>
<span>{{ i18n.ts._visibility.localOnly }}</span>
<span>{{ i18n.ts._visibility.localOnlyDescription }}</span>

View File

@ -1,10 +1,11 @@
<template>
<span v-if="note.visibility !== 'public'" :class="$style.visibility">
<i v-if="note.visibility === 'home'" class="fas fa-home"></i>
<i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
<span :class="$style.visibility">
<i v-if="note.visibility === 'public'" class="fas fa-bullhorn"></i>
<i v-else-if="note.visibility === 'home'" class="fas fa-lock-open"></i>
<i v-else-if="note.visibility === 'followers'" class="fas fa-lock"></i>
<i v-else-if="note.visibility === 'specified'" ref="specified" class="fas fa-envelope"></i>
</span>
<span v-if="note.localOnly" :class="$style.localOnly"><i class="fas fa-biohazard"></i></span>
<span v-if="note.localOnly" :class="$style.localOnly"><i class="fas fa-tree-city"></i></span>
</template>
<script lang="ts" setup>

View File

@ -171,7 +171,6 @@ import { getAccountFromId } from '@/scripts/get-account-from-id';
const app = createApp(
window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) :
defineAsyncComponent(() => import('@/ui/universal.vue')),
);

View File

@ -169,13 +169,6 @@ export const menuDef = reactive({
localStorage.setItem('ui', 'default');
unisonReload();
},
}, {
text: i18n.ts.deck,
active: ui === 'deck',
action: () => {
localStorage.setItem('ui', 'deck');
unisonReload();
},
}, {
text: i18n.ts.classic,
active: ui === 'classic',

View File

@ -527,24 +527,6 @@ export function popupMenu(items: MenuItem[] | Ref<MenuItem[]>, src?: HTMLElement
});
}
export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent) {
ev.preventDefault();
return new Promise((resolve) => {
let dispose;
popup(defineAsyncComponent(() => import('@/components/ui/context-menu.vue')), {
items,
ev,
}, {
closed: () => {
resolve();
dispose();
},
}).then(res => {
dispose = res.dispose;
});
});
}
export function post(props: Record<string, any> = {}) {
return new Promise((resolve) => {
// NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない
@ -564,8 +546,6 @@ export function post(props: Record<string, any> = {}) {
});
}
export const deckGlobalEvents = new EventEmitter();
/*
export function checkExistence(fileData: ArrayBuffer): Promise<any> {
return new Promise((resolve) => {

View File

@ -157,11 +157,6 @@ const menuDef = $computed(() => [{
text: i18n.ts.relays,
to: '/admin/relays',
active: props.initialPage === 'relays',
}, {
icon: 'fas fa-share-alt',
text: i18n.ts.integration,
to: '/admin/integrations',
active: props.initialPage === 'integrations',
}, {
icon: 'fas fa-ban',
text: i18n.ts.instanceBlocking,
@ -199,7 +194,6 @@ const component = $computed(() => {
case 'object-storage': return defineAsyncComponent(() => import('./object-storage.vue'));
case 'security': return defineAsyncComponent(() => import('./security.vue'));
case 'relays': return defineAsyncComponent(() => import('./relays.vue'));
case 'integrations': return defineAsyncComponent(() => import('./integrations.vue'));
case 'instance-block': return defineAsyncComponent(() => import('./instance-block.vue'));
case 'proxy-account': return defineAsyncComponent(() => import('./proxy-account.vue'));
default: return null;

View File

@ -1,59 +0,0 @@
<template>
<FormSuspense :p="init">
<div class="_formRoot">
<FormSwitch v-model="enableDiscordIntegration" class="_formBlock">
<template #label>{{ i18n.ts.enable }}</template>
</FormSwitch>
<template v-if="enableDiscordIntegration">
<FormInfo class="_formBlock">Callback URL: {{ `${uri}/api/dc/cb` }}</FormInfo>
<FormInput v-model="discordClientId" class="_formBlock">
<template #prefix><i class="fas fa-key"></i></template>
<template #label>Client ID</template>
</FormInput>
<FormInput v-model="discordClientSecret" class="_formBlock">
<template #prefix><i class="fas fa-key"></i></template>
<template #label>Client Secret</template>
</FormInput>
</template>
<FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton>
</div>
</FormSuspense>
</template>
<script lang="ts" setup>
import FormSwitch from '@/components/form/switch.vue';
import FormInput from '@/components/form/input.vue';
import FormButton from '@/components/ui/button.vue';
import FormInfo from '@/components/ui/info.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os';
import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n';
let uri: string = $ref('');
let enableDiscordIntegration: boolean = $ref(false);
let discordClientId: string | null = $ref(null);
let discordClientSecret: string | null = $ref(null);
async function init() {
const meta = await os.api('admin/meta');
uri = meta.uri;
enableDiscordIntegration = meta.enableDiscordIntegration;
discordClientId = meta.discordClientId;
discordClientSecret = meta.discordClientSecret;
}
function save() {
os.apiWithDialog('admin/update-meta', {
enableDiscordIntegration,
discordClientId,
discordClientSecret,
}).then(() => {
fetchInstance();
});
}
</script>

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