1
0
mirror of https://github.com/hotomoe/hotomoe synced 2024-11-24 23:26:17 +09:00

Merge branch 'develop'

This commit is contained in:
syuilo 2021-02-19 21:42:47 +09:00
commit d6c8b9b994
179 changed files with 5699 additions and 964 deletions

View File

@ -1 +1 @@
v14.15.1
v14.15.4

View File

@ -240,36 +240,6 @@ SQLでは配列のインデックスは**1始まり**。
MongoDBの時とは違い、findOneでレコードを取得する時に対象レコードが存在しない場合 **`undefined`** が返ってくるので注意。
MongoDBは`null`で返してきてたので、その感覚で`if (x === null)`とか書くとバグる。代わりに`if (x == null)`と書いてください
### 簡素な`undefined`チェック
データベースからレコードを取得するときに、プログラムの流れ的に(ほぼ)絶対`undefined`にはならない場合でも、`undefined`チェックしないとTypeScriptに怒られます。
でもいちいち複数行を費やして、発生するはずのない`undefined`をチェックするのも面倒なので、`ensure`というユーティリティ関数を用意しています。
例えば、
``` ts
const user = await Users.findOne(userId);
// この時点で user の型は User | undefined
if (user == null) {
throw 'missing user';
}
// この時点で user の型は User
```
という処理を`ensure`を使うと
``` ts
const user = await Users.findOne(userId).then(ensure);
// この時点で user の型は User
```
という風に書けます。
もちろん`ensure`内部でエラーを握りつぶすようなことはしておらず、万が一`undefined`だった場合はPromiseがRejectされ後続の処理は実行されません。
``` ts
const user = await Users.findOne(userId).then(ensure);
// 万が一 Users.findOne の結果が undefined だったら、ensure でエラーが発生するので
// この行に到達することは無い
// なので、.then(ensure) は
// if (user == null) {
// throw 'missing user';
// }
// の糖衣構文のような扱いです
```
### Migration作成方法
```
npx ts-node ./node_modules/typeorm/cli.js migration:generate -n 変更の名前

View File

@ -1,4 +1,4 @@
FROM node:14.15.1-alpine AS base
FROM node:14.15.4-alpine AS base
ENV NODE_ENV=production

BIN
assets/mi-white.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -88,6 +88,7 @@ enterEmoji: "أدخل إيموجي"
unrenote: "إلغاء مشاركة الملاحظة"
quote: "اقتبس"
pinnedNote: "ملاحظة مدبسة"
pinned: "دبّسها على الصفحة الشخصية"
you: "أنت"
clickToShow: "اضغط للعرض"
sensitive: "محتوى حساس"
@ -428,6 +429,9 @@ latestVersion: "آخر نسخة مستقرة"
usageAmount: "الإستخدام"
capacity: "السعة"
inUse: "مستخدم"
_email:
_follow:
title: "يتابعك"
_mfm:
mention: "أشر الى"
quote: "اقتبس"

View File

@ -97,6 +97,7 @@ cantRenote: "Renote dieses Beitrags nicht möglich."
cantReRenote: "Renote einer Renote nicht möglich."
quote: "Zitieren"
pinnedNote: "Angepinnte Notiz"
pinned: "Anheften"
you: "Du"
clickToShow: "Klicke, um diesen Inhalt anzusehen"
sensitive: "NSFW"
@ -437,6 +438,7 @@ signinWith: "Mit {x} anmelden"
signinFailed: "Anmeldung fehlgeschlagen. Überprüfe Benutzername und Passswort."
tapSecurityKey: "Tippe deinen Sicherheitsschlüssel an"
or: "Oder"
language: "Sprache"
uiLanguage: "Sprache der Benutzeroberfläche"
groupInvited: "Du wurdest in eine Gruppe eingeladen"
aboutX: "Über {x}"
@ -700,7 +702,13 @@ capacity: "Kapazität"
inUse: "Verwendet"
editCode: "Code bearbeiten"
apply: "Anwenden"
receiveAnnouncementFromInstance: "Benachrichtigungen von der Instanz empfangen"
receiveAnnouncementFromInstance: "E-Mail-Benachrichtigungen von dieser Instanz empfangen"
emailNotification: "E-Mail-Benachrichtigungen"
_email:
_follow:
title: "Du hast einen neuen Follower"
_receiveFollowRequest:
title: "Du hast eine Follow-Anfrage erhalten"
_plugin:
install: "Plugins installieren"
installWarn: "Installiere bitte nur vertrauenswürdige Plugins."
@ -1508,7 +1516,7 @@ _notification:
youGotPoll: "{name} hat auf deiner Umfrage abgestimmt"
youGotMessagingMessageFromUser: "{name} hat dir eine Chatnachricht gesendet"
youGotMessagingMessageFromGroup: "In die Gruppe {name} wurde eine Chatnachricht gesendet"
youWereFollowed: "Du hast einen neuen Follower"
youWereFollowed: "ist dir gefolgt"
youReceivedFollowRequest: "Du hast eine Follow-Anfrage erhalten"
yourFollowRequestAccepted: "Deine Follow-Anfrage wurde akzeptiert"
youWereInvitedToGroup: "Du wurdest in eine Gruppe eingeladen"

View File

@ -97,6 +97,7 @@ cantRenote: "This post can't be renoted."
cantReRenote: "A renote can't be renoted."
quote: "Quote"
pinnedNote: "Pinned note"
pinned: "Pin to profile"
you: "You"
clickToShow: "Click to show"
sensitive: "NSFW"
@ -437,6 +438,7 @@ signinWith: "Sign in with {x}"
signinFailed: "Unable to sign in. The username or password you entered is incorrect."
tapSecurityKey: "Tap your security key"
or: "Or"
language: "Language"
uiLanguage: "UI display language"
groupInvited: "Invited to group"
aboutX: "About {x}"
@ -700,7 +702,13 @@ capacity: "Capacity"
inUse: "Used"
editCode: "Edit code"
apply: "Apply"
receiveAnnouncementFromInstance: "Receive notifications from the instance"
receiveAnnouncementFromInstance: "Receive Email notifications from this instance"
emailNotification: "Email notifications"
_email:
_follow:
title: "You've got a new follower"
_receiveFollowRequest:
title: "You've received a follow request"
_plugin:
install: "Install plugins"
installWarn: "Please do not install untrustworthy plugins."

View File

@ -96,6 +96,7 @@ cantRenote: "No se puede renotar este post"
cantReRenote: "No se puede renotar una renota"
quote: "Citar"
pinnedNote: "Nota fijada"
pinned: "Fijar"
you: "Tú"
clickToShow: "Click para ver"
sensitive: "Marcado como sensible"
@ -651,6 +652,9 @@ backgroundColor: "Fondo"
accentColor: "Acento"
textColor: "Texto"
value: "Valores"
_email:
_follow:
title: "te ha seguido"
_registry:
key: "Clave"
keys: "Clave"

View File

@ -96,6 +96,7 @@ renoted: "Republier"
cantRenote: "Ce message ne peut pas être republié."
quote: "Citer"
pinnedNote: "Note épinglée"
pinned: "Épingler sur le profil"
you: "Vous"
clickToShow: "Cliquer pour afficher"
sensitive: "Contenu sensible"
@ -648,6 +649,9 @@ closeAccount: "Fermer le compte"
usageAmount: "Utilisation"
capacity: "Capacité "
inUse: "utilisé"
_email:
_follow:
title: "Vous suit"
_registry:
key: "Clé "
keys: "Clé "

View File

@ -1,5 +1,7 @@
---
_lang_: "Bahasa Jepang"
headlineMisskey: "Catatan terhubung jaringan"
introMisskey: "Selamat datang! Misskey adalah perangkat mikroblog tercatu bersifat sumber terbuka.\nMulailah menuliskan catatan, bagikan peristiwa terkini, serta ceritakan segala tentangmu.📡\nTunjukkan juga reaksimu pada catatan pengguna lain.👍\nMari jelajahi dunia baru🚀"
monthAndDay: "{day} {month}"
search: "Pencarian"
notifications: "Notifikasi"
@ -44,9 +46,30 @@ sendMessage: "Kirim pesan"
copyUsername: "Salin nama pengguna"
searchUser: "Cari pengguna"
reply: "Balas"
loadMore: "Selebihnya"
showMore: "Selebihnya"
youGotNewFollower: "Sedang mengikuti"
receiveFollowRequest: "Permintaan mengikuti terkirim"
mention: "Panggilan"
files: "Berkas"
download: "Unduh"
driveFileDeleteConfirm: "Hapus {name}? Catatan dengan berkas terkait juga akan terhapus."
unfollowConfirm: "Berhenti mengikuti {name}?"
following: "Ikuti"
followers: "Pengikut"
followsYou: "Mengikuti Anda"
error: "Galat"
somethingHappened: "Terjadi kesalahan"
retry: "Coba lagi"
pageLoadError: "Gagal memuat halaman."
pageLoadErrorDescription: "Umumnya disebabkan jaringan atau tembolok perambah. Cobalah bersihkan tembolok peramban lalu tunggu sesaat sebelum mencoba kembali."
privacy: "Keleluasaan"
follow: "Ikuti"
unfollow: "Berhenti mengikuti"
cantReRenote: "Renote tidak dapat direnote"
quote: "Kutip"
pinnedNote: "Note yang disematkan"
pinned: "Sematkan ke profil"
you: "Anda"
clickToShow: "Klik untuk melihat"
sensitive: "Konten sensitif"
@ -190,26 +213,41 @@ invites: "Undang"
invitations: "Undang"
smtpUser: "Nama Pengguna"
smtpPass: "Kata sandi"
_email:
_follow:
title: "Sedang mengikuti"
_mfm:
mention: "Panggilan"
quote: "Kutip"
emoji: "Emoji kustom"
search: "Pencarian"
_theme:
keys:
mention: "Panggilan"
_sfx:
notification: "Notifikasi"
chat: "Pesan"
_widgets:
notifications: "Notifikasi"
timeline: "Linimasa"
_cw:
show: "Selebihnya"
_visibility:
followers: "Pengikut"
_profile:
username: "Nama Pengguna"
_exportOrImport:
followingList: "Ikuti"
muteList: "Bisukan"
blockingList: "Blokir"
_rooms:
_roomType:
default: "Bawaan"
_notification:
youWereFollowed: "Sedang mengikuti"
_types:
follow: "Ikuti"
mention: "Panggilan"
quote: "Kutip"
reaction: "Reaksi"
_deck:

View File

@ -1,67 +1,574 @@
---
_lang_: "Italiano"
monthAndDay: "{day}/{month}"
search: "Cerca"
notifications: "Notifiche"
username: "Nome utente"
password: "Password"
ok: "OK"
cancel: "Annulla"
enterUsername: "Inserisci un nome utente"
renotedBy: "Rinotta da {user}"
noNotes: "Nessuna note"
noNotifications: "Nessuna notifica"
instance: "Istanza"
settings: "Impostazioni"
basicSettings: "Impostazioni generali"
otherSettings: "Altre impostazioni"
profile: "Profilo"
timeline: "Timeline"
login: "Login"
login: "Accedi"
logout: "Logout"
signup: "Iscriviti"
uploading: "Caricamento..."
save: "Salva"
users: "Utente"
favorite: "Segnalibri"
favorites: "Segnalibri"
unfavorite: "Rimuovi Nota dai segnalibri"
favorited: "Nota salvato nei segnalibri."
alreadyFavorited: "Tweet salvato nei segnalibri."
pin: "Fissa sul profilo"
unpin: "Non fissare più sul profilo"
copyContent: "Copia il contenuto del Nota"
copyLink: "Copia link"
delete: "Elimina"
deleteAndEdit: "Elimina & Modifica"
addToList: "Aggiungi alla lista"
sendMessage: "Invia messaggio"
copyUsername: "Copia nome utente"
searchUser: "Cerca Utente"
reply: "Rispondi"
loadMore: "Mostra altre"
showMore: "Mostra altre"
youGotNewFollower: "Nuovo seguace"
receiveFollowRequest: "Nuova richiesta di essere seguito"
mention: "Menzioni"
mentions: "Menzioni"
directNotes: "Note diretti"
importAndExport: "Importa ed Esporta"
import: "Importa"
note: "Note"
notes: "Notes"
export: "Esporta"
files: "Allegato"
download: "Scarica"
lists: "Liste"
noLists: "Qui non c'è ancora niente"
note: "Nota"
notes: "Nota"
following: "Seiguiti"
followers: "Seguaci"
followsYou: "Ti segue"
createList: "Crea una nuova lista"
manageLists: "Modifica lista"
error: "Errore"
somethingHappened: "Qualcosa è andato storto."
retry: "Riprova"
enterListName: "Inserisci il nome della lista"
privacy: "Privacy"
quote: "Cita Note"
follow: "Segui"
followRequest: "Richiesta di seguire"
followRequests: "Richiesta di seguire"
unfollow: "Smetti di seguire"
followRequestPending: "In sospeso"
renote: "Rinotta"
unrenote: "Annulla rinotta"
quote: "Cita Nota"
pinned: "Fissa sul profilo"
you: "Tu"
clickToShow: "Clicca per visualizzare"
sensitive: "Contenuto sensibile"
add: "Aggiungi"
reaction: "Reazione"
attachCancel: "Rimuovi allegato"
markAsSensitive: "Segna come sensibile"
unmarkAsSensitive: "Segna come non sensibile"
mute: "Silenzia"
unmute: "Riattiva"
block: "Blocca"
unblock: "Sblocca"
suspend: "Sospendi"
unsuspend: "Annulla la sospensione dell'account"
blockConfirm: "Vuoi bloccare?"
unblockConfirm: "Vuoi sbloccare?"
editWidgetsExit: "Modifica fine"
emoji: "Emoji"
addAcount: "Aggiungi un account esistente"
general: "Generali"
wallpaper: "Sfondo"
setWallpaper: "Imposta sfondo"
searchWith: "Cerca: {q}"
annotation: "Descrizione"
federation: "Federazione"
instances: "Istanza"
storageUsage: "Volume di dischi"
charts: "Grafici"
perHour: "All'ora"
perDay: "al giorno"
software: "Software"
version: "Versione"
metadata: "Metadato"
network: "Rete"
disk: "Disco"
statistics: "Statistiche"
blockedInstances: "Istanza bloccati"
muteAndBlock: "Silenziamento e blocco"
mutedUsers: "Account silenziati"
blockedUsers: "Account bloccati"
editProfile: "Modifica profilo"
noteDeleteConfirm: "Eliminare questo Nota?"
done: "Fine"
processing: "In elaborazione"
blocked: "Bloccati"
all: "Tutti"
notResponding: "Nessuna risposta"
changePassword: "Aggiorna Password"
security: "Sicurezza"
retypedNotMatch: "Le password non corrispondono."
currentPassword: "Password attuale"
newPassword: "Nuova Password"
newPasswordRetype: "Conferma nuova password"
more: "Altri!"
lookup: "Cercare"
announcements: "Annuncio"
imageUrl: "URL dell'immagine"
remove: "Elimina"
removed: "Il tuo Tweet è stato eliminato"
removeAreYouSure: "Eliminare \"{x}\"?"
deleteAreYouSure: "Eliminare \"{x}\"?"
resetAreYouSure: "Reimposta"
saved: "Salvato"
messaging: "Messaggi"
upload: "Carica"
uploadFromUrl: "Incolla URL immagine"
explore: "Esplora"
games: "Misskey Giochi"
messageRead: "Visualizzato"
startMessaging: "Nuovo messaggio"
tos: "Termini di servizio"
home: "Home"
images: "Immagini"
birthday: "Compleanno"
yearsOld: "{age}Anni"
registeredDate: "Iscrizione a.."
location: "Posizione"
theme: "Tema"
light: "Chiaro"
dark: "Scuro"
lightThemes: "Tema Chiaro"
darkThemes: "Tema Scuro"
drive: "Drive"
fileName: "Nome dell'allegato"
copyUrl: "Copia URL"
rename: "Modifica nome"
avatar: "Foto del profilo"
banner: "Foto d'intestazione"
nsfw: "Contenuti sensibili"
reload: "Ricarica"
watch: "Osserva"
unwatch: "Smetti di Osserva"
accept: "Accetta"
reject: "Rifiuta"
normal: "Normale"
instanceName: "Nome dell'istanza"
instanceDescription: "Descrizione dell'istanza"
maintainerName: "Nome dell'Amministratore"
maintainerEmail: "Indirizzo e-mail dell'Amministratore"
tosUrl: "Termini di servizio URL"
thisYear: "Anno"
thisMonth: "Mese"
today: "Oggi"
dayX: "{day}"
monthX: "{month}"
yearX: "{year}"
integration: "App collegate"
connectSerice: "Connetti"
disconnectSerice: "Disconnetti"
registration: "Iscriviti"
invite: "Invita"
bannerUrl: "indirizzo Foto d'intestazione"
basicInfo: "Informazioni fondamentali"
hcaptcha: "hCaptcha"
enableHcaptcha: "Abilita hCaptcha"
recaptcha: "reCAPTCHA"
enableRecaptcha: "Abilita reCAPTCHA"
name: "Nome"
serviceworker: "ServiceWorker"
exploreFediverse: "Esplora Fediverse"
popularTags: "Tag di tendenza"
userList: "Liste"
about: "Informazioni"
aboutMisskey: "Informazioni di Misskey"
administrator: "Amministratore"
token: "Token"
twoStepAuthentication: "Autenticazione a due fattori"
moderator: "Moderatore"
lastUsed: "Ultima attività"
unregister: "Disattiva account"
resetPassword: "Reimposta password"
share: "Condividi"
cacheClear: "Svuota cache"
help: "Guida"
close: "Chiudi"
group: "Gruppo"
groups: "Gruppi"
createGroup: "Nuovo gruppo"
invites: "Invita"
transfer: "Trasferisci"
title: "Titolo"
next: "Avanti"
invitations: "Invita"
invitationCode: "Codice di invito"
available: "Consigliati"
unavailable: "Il nome utente è già in uso"
usernameInvalidFormat: "Il nome utente può contenere solo lettere, numeri e '_'"
tooShort: "Troppo breve"
tooLong: "Troppo lungo"
passwordNotMatched: "Le password non corrispondono."
signinHistory: "Cronologia di accesso all'account"
tags: "Tag"
createAccount: "Crea il tuo account"
existingAcount: "Account esistente"
local: "Locale"
remote: "Remoto"
accountSettings: "Impostazioni Account"
promote: "Pubblicizza"
objectStorageBucket: "Bucket"
objectStorageEndpoint: "Endpoint"
objectStorageRegion: "Region"
serverLogs: "Log del server"
deleteAll: "Cancella cronologia"
volume: "Volume"
details: "Dettagli"
install: "Installa"
uninstall: "Disinstalla"
installedDate: "Data installazione"
sort: "Ordina per"
visibility: "Privacy dei post"
poll: "Sondaggio"
useCw: "Nascondere media"
description: "Descrizione"
author: "Autore"
width: "Larghezza"
height: "Altezza"
large: "Grande"
medium: "Predefinito"
small: "Piccolo"
edit: "Modifica"
email: "Email"
smtpUser: "Nome utente"
smtpPass: "Password"
wordMute: "Parole silenziate"
display: "Visualizza"
copy: "Copia"
logs: "Log"
database: "Base di dati"
channel: "Canale"
notificationSetting: "impostazioni delle notifiche"
other: "Avanzate"
abuseReports: "Segnala"
reportAbuse: "Segnala"
reportAbuseOf: "Segnala {name}"
random: "Casuale"
system: "Sistema"
optional: "Opzionale"
public: "Pubblico"
yes: "Sì"
no: "No"
contact: "Contatti"
developer: "Sviluppatore"
duplicate: "Duplica"
left: "Sinistra"
center: "Centro"
wide: "Largo"
nNotes: "{n}Nota"
backgroundColor: "Sfondo"
value: "Valore"
saveConfirm: "Vuoi salvare le modifiche?"
deleteConfirm: "Rimuovere?"
registry: "Registro"
closeAccount: "Disattiva account"
currentVersion: "Versione attuale"
latestVersion: "Ultima versione"
editCode: "Modifica codice"
apply: "Applica"
_email:
_follow:
title: "Nuovo seguace"
_registry:
key: "Dati"
keys: "Dati"
_aboutMisskey:
morePatrons: "Ci sono molti altri che ci sostengono. Grazie 🥰"
_mfm:
mention: "Menzioni"
quote: "Cita Note"
url: "URL"
link: "Link"
bold: "Grassetto"
blockCode: "Codice(blocco)"
inlineMath: "Espressione matematica(Immersione)"
blockMath: "Espressione matematica(blocco)"
quote: "Cita il nota"
search: "Cerca"
blur: "Sfocatura"
font: "Tipo di carattere"
_reversi:
black: "Nero"
white: "Bianco"
ended: "Esci"
_channel:
featured: "Tendenze"
_sidebar:
icon: "Foto del profilo"
hide: "Nascondere"
_theme:
constant: "Costante"
defaultValue: "Valore predefinito"
color: "Colore"
func: "Funzione"
darken: "Scuro"
lighten: "Chiaro"
keys:
bg: "Sfondo"
shadow: "Ombra"
mention: "Menzioni"
renote: "Rinotta"
divider: "Interruzione di linea"
_sfx:
note: "Notes"
note: "Nota"
notification: "Notifiche"
chat: "Messaggi"
_ago:
unknown: "Sconosciuto"
future: "Futuro"
justNow: "Ora"
secondsAgo: "{n}s fa"
minutesAgo: "{n}min fa"
hoursAgo: "{n}h fa"
daysAgo: "{1} giorni fa"
weeksAgo: "{n} settimane fa"
monthsAgo: "{n} mesi fa"
yearsAgo: "{n} anni fa"
_time:
second: "s"
minute: "min"
hour: "ore"
day: "giorni"
_tutorial:
title: "Come usare Misskey"
step1_1: "Benvenuto"
_permissions:
"read:blocks": "Visualizza gli account che hai bloccato."
"write:blocks": "Gestisci gli account che hai bloccato."
"read:favorites": "Visualizza Segnalibri"
"write:favorites": "Gestisci Segnalibri"
"write:following": "Seguiti/ Smetti di seguire"
"read:notifications": "Visualizza notifiche"
_weekday:
sunday: "Domenica"
monday: "Lunedì"
tuesday: "Martedì"
wednesday: "Mercoledì"
thursday: "Giovedì"
friday: "Venerdì"
saturday: "Sabato"
_widgets:
memo: "Memo"
notifications: "Notifiche"
timeline: "Timeline"
calendar: "Calendario"
trends: "Tendenze"
clock: "Orologio"
rss: "Aggregatore rss"
activity: "Attività"
photos: "Foto"
digitalClock: "Orologio digitale"
federation: "Federazione"
_cw:
hide: "Nascondere"
show: "Mostra altre"
_poll:
noMore: "Hai aggiunto il numero massimo di opzioni."
canMultipleVote: "Risposte multiple"
expiration: "Scadenza"
infinite: "Permanente"
deadlineDate: "Data di scadenza"
deadlineTime: "h"
voted: "Votato"
closed: "Terminato"
_visibility:
public: "Pubblico"
home: "Home"
followers: "Seguaci"
localOnly: "Solo Locale"
localOnlyDescription: "Solo locale"
_postForm:
replyPlaceholder: "Nota la tua risposta.."
quotePlaceholder: "Cita Nota..."
_profile:
name: "Nome"
username: "Nome utente"
description: "Bio"
metadata: "Metadati"
metadataLabel: "Etichetta"
metadataContent: "Contenuto"
_exportOrImport:
followingList: "Seiguiti"
muteList: "Silenzia"
blockingList: "Blocca"
userLists: "Liste"
_timelines:
home: "Home"
local: "Locale"
_rooms:
_roomType:
washitsu: "Washitsu"
_furnitures:
milk: "Cartone del latte"
bed: "Letto"
low-table: "Tavolino Coffee"
desk: "Tavolo"
chair: "Sedia"
chair2: "Sedia 2"
fan: "Ventilatore"
pc: "PC"
plant: "Pianta da appartamento"
plant2: "Pianta da appartamento2"
eraser: "Gomma"
pencil: "Matita"
pudding: "Pudding"
book: "Libro"
book2: "Libro2"
piano: "Pianoforte"
server: "Server"
moon: "Luna"
corkboard: "Bacheca"
mousepad: "Tappetino per il mouse"
monitor: "Monitor "
keyboard: "Tastiera"
mat: "Zerbino"
color-box: "Libreria"
wall-clock: "Orologio da parete"
photoframe: "Cornice"
cube: "Cubo"
tv: "Televisore"
pinguin: "Pinguini"
bin: "Cestino"
cup-noodle: "Noodle istantanei"
_pages:
like: "Mi piace"
unlike: "Togli Mi piace"
variables: "Variabili"
title: "Titolo"
font: "Tipo di carattere"
blocks:
image: "Immagini"
if: "Se"
_if:
variable: "Variabili"
_post:
text: "Contenuto"
_textInput:
text: "Titolo"
_textareaInput:
text: "Titolo"
_numberInput:
text: "Titolo"
_switch:
text: "Titolo"
_counter:
text: "Titolo"
_button:
text: "Titolo"
_action:
_dialog:
content: "Contenuto"
_radioButton:
title: "Titolo"
script:
categories:
comparison: "Metodo comparativo"
random: "Aleatorietà"
value: "Valore"
fn: "Funzione"
list: "Liste"
blocks:
_join:
arg1: "Liste"
_add:
arg1: "A"
arg2: "B"
_subtract:
arg1: "A"
arg2: "B"
_multiply:
arg1: "A"
arg2: "B"
_divide:
arg1: "A"
arg2: "B"
_mod:
arg1: "A"
arg2: "B"
_eq:
arg1: "A"
arg2: "B"
notEq: "A non è uguale a B"
_notEq:
arg1: "A"
arg2: "B"
and: "A e B"
_and:
arg1: "A"
arg2: "B"
or: "A o B"
_or:
arg1: "A"
arg2: "B"
_lt:
arg1: "A"
arg2: "B"
_gt:
arg1: "A"
arg2: "B"
_ltEq:
arg1: "A"
arg2: "B"
_gtEq:
arg1: "A"
arg2: "B"
_if:
arg1: "Se"
random: "Aleatorietà"
_randomPick:
arg1: "Liste"
_dailyRandomPick:
arg1: "Liste"
_seedRandomPick:
arg2: "Liste"
_pick:
arg1: "Liste"
_listLen:
arg1: "Liste"
ref: "Variabili"
fn: "Funzione"
types:
array: "Liste"
_notification:
youGotQuote: "{name} ha citato il tuo Nota e ha detto"
youRenoted: "{name} ha rinotta"
youGotPoll: "{name} ha volluto."
youWereFollowed: "Nuovo seguace"
_types:
all: "Tutto"
follow: "Seiguiti"
mention: "Menzioni"
quote: "Cita Note"
reply: "Rispondi"
renote: "Rinotta"
quote: "Cita il nota"
reaction: "Reazione"
_deck:
_columns:
notifications: "Notifiche"
tl: "Timeline"
list: "Liste"
mentions: "Menzioni"

View File

@ -97,6 +97,7 @@ cantRenote: "この投稿はRenoteできません。"
cantReRenote: "RenoteをRenoteすることはできません。"
quote: "引用"
pinnedNote: "ピン留めされたノート"
pinned: "ピン留め"
you: "あなた"
clickToShow: "クリックして表示"
sensitive: "閲覧注意"
@ -437,6 +438,7 @@ signinWith: "{x}でログイン"
signinFailed: "ログインできませんでした。ユーザー名とパスワードを確認してください。"
tapSecurityKey: "セキュリティキーにタッチ"
or: "もしくは"
language: "言語"
uiLanguage: "UIの表示言語"
groupInvited: "グループに招待されました"
aboutX: "{x}について"
@ -701,6 +703,14 @@ inUse: "使用中"
editCode: "コードを編集"
apply: "適用"
receiveAnnouncementFromInstance: "インスタンスからのお知らせを受け取る"
emailNotification: "メール通知"
inChannelSearch: "チャンネル内検索"
_email:
_follow:
title: "フォローされました"
_receiveFollowRequest:
title: "フォローリクエストを受け取りました"
_plugin:
install: "プラグインのインストール"

View File

@ -96,6 +96,7 @@ cantRenote: "この投稿はRenoteできへんらしい。"
cantReRenote: "Renote自体はRenoteできへんで。"
quote: "引用"
pinnedNote: "ピン留めされとるノート"
pinned: "ピン留めしとく"
you: "あんた"
clickToShow: "押したら見えるで"
sensitive: "ちょっとアカンやつやで"
@ -459,6 +460,7 @@ emailConfigInfo: "メールアドレスの確認とかパスワードリセッ
smtpHost: "ホスト"
smtpUser: "ユーザー名"
smtpPass: "パスワード"
notificationSettingDesc: "表示する通知の種類えらんでや。"
emailVerified: "メールアドレスは確認されたで"
pageLikesCount: "Pageにええやんと思った数"
pageLikedCount: "Pageにええやんと思ってくれた数"
@ -468,6 +470,9 @@ onlineUsersCount: "{n}人が起きとるで"
sendErrorReportsDescription: "オンにしたら、なんか変なことが起きたときにエラーの詳細がMisskeyに共有されて、ソフトウェアの品質向上に役立てられるんや。エラー情報には、OSのバージョン、ブラウザの種類、行動履歴などが含まれるで。"
youAreRunningUpToDateClient: "今使ってるクライアントが最新やで!"
newVersionOfClientAvailable: "新しいバージョンのクライアントが使えるで。"
_email:
_follow:
title: "フォローされたで"
_mfm:
mention: "メンション"
quote: "引用"

View File

@ -36,6 +36,9 @@ userList: "Tibdarin"
uiLanguage: "Tutlayt n wegrudem"
smtpUser: "Isem n umseqdac"
smtpPass: "Awal uffir"
_email:
_follow:
title: "Yeṭṭafaṛ-ik·em-id"
_mfm:
mention: "Bder"
search: "Nadi"

View File

@ -53,10 +53,14 @@ files: "ಕಡತಗಳು"
download: "ಜಾಲದಿಂದಿಳಿಸು"
driveFileDeleteConfirm: "\"{name}\" ಕಡತವನ್ನು ಅಳಿಸಲು ನೀವು ಬಯಸುವಿರಾ? ಈ ನೋಡಿರಿ ಲಗತ್ತಿಸಲಾದ ಟಿಪ್ಪಣಿ ಸಹ ಕಣ್ಮರೆಯಾಗುತ್ತದೆ."
unfollowConfirm: "{name}ಅನ್ನು ಹಿಂಬಾಲಿಸದಿರುವುದೇ?"
pinned: "ಪ್ರೊಫ಼ೈಲಿಗೆ ಅಂಟಿಸು"
instances: "ನಿದರ್ಶನ"
remove: "ಅಳಿಸು"
smtpUser: "ಬಳಕೆಹೆಸರು"
smtpPass: "ಗುಪ್ತಪದ"
_email:
_follow:
title: "ಹಿಂಬಾಲಿಸಿದರು"
_mfm:
search: "ಹುಡುಕು"
_sfx:

View File

@ -1,5 +1,6 @@
---
_lang_: "한국어"
headlineMisskey: "노트로 연결되는 네트워크"
introMisskey: "환영합니다! Misskey 는 오픈 소스 분산형 마이크로 블로그 서비스입니다.\n\"노트\" 를 작성해서, 지금 일어나고 있는 일을 공유하거나, 당신만의 이야기를 모두에게 발신하세요📡\n\"리액션\" 기능으로, 친구의 노트에 총알같이 반응을 추가할 수도 있습니다👍\n새로운 세계를 탐험해 보세요🚀"
monthAndDay: "{month}월 {day}일"
search: "검색"
@ -96,6 +97,7 @@ cantRenote: "이 게시물은 Renote할 수 없습니다."
cantReRenote: "Renote를 Renote할 수 없습니다."
quote: "인용"
pinnedNote: "고정해놓은 노트"
pinned: "프로필에 고정"
you: "당신"
clickToShow: "클릭하여 보기"
sensitive: "열람주의"
@ -436,6 +438,7 @@ signinWith: "{x}로 로그인"
signinFailed: "로그인할 수 없습니다. 사용자명과 비밀번호를 확인하여 주십시오."
tapSecurityKey: "보안 키를 터치"
or: "혹은"
language: "언어"
uiLanguage: "UI 표시 언어"
groupInvited: "그룹에 초대되었습니다"
aboutX: "{x}에 대하여"
@ -687,6 +690,14 @@ deleteConfirm: "삭제하시겠습니까?"
invalidValue: "올바른 값이 아닙니다."
registry: "레지스트리"
closeAccount: "계정 폐쇄"
usageAmount: "사용량"
capacity: "용량"
inUse: "사용중"
editCode: "코드 수정"
apply: "적용"
_email:
_follow:
title: "새로운 팔로워가 있습니다"
_registry:
scope: "범위"
key: "키"
@ -834,6 +845,7 @@ _theme:
deleteConstantConfirm: "상수 {const}를 삭제하시겠습니까?"
keys:
accent: "강조 색상"
panel: "패널"
link: "링크"
hashtag: "해시태그"
mention: "멘션"
@ -1126,6 +1138,7 @@ _pages:
created: "페이지를 만들었습니다"
updated: "페이지를 수정했습니다"
deleted: "페이지가 삭제되었습니다"
pageSetting: "페이지 설정"
nameAlreadyExists: "지정한 페이지 URL이 이미 존재합니다"
invalidNameTitle: "유효하지 않은 페이지 URL입니다"
invalidNameText: "비어있지 않은지 확인해주세요"
@ -1136,6 +1149,7 @@ _pages:
unlike: "좋아요 해제"
my: "내 페이지"
liked: "좋아요한 페이지"
featured: "인기"
inspector: "인스펙터"
contents: "콘텐츠"
content: "페이지 블록"
@ -1191,7 +1205,10 @@ _pages:
id: "캔버스 ID"
width: "폭"
height: "높이"
note: "노트필기"
_note:
id: "노트 ID"
idDescription: "노트 URL을 붙여넣어 설정할 수도 있습니다."
detailed: "세부 정보 보기"
switch: "스위치"
_switch:
@ -1434,7 +1451,9 @@ _deck:
swapDown: "아래로 이동"
stackLeft: "왼쪽에 쌓기"
popRight: "오른쪽으로 빼기"
profile: "프로파일"
_columns:
main: "메인"
widgets: "위젯"
notifications: "알림"
tl: "타임라인"

View File

@ -93,6 +93,7 @@ cantRenote: "Ten wpis nie może zostać udostępniony."
cantReRenote: "Udostępnienie nie może zostać udostępnione."
quote: "Cytuj"
pinnedNote: "Przypięty wpis"
pinned: "Przypnij do profilu"
you: "Ty"
clickToShow: "Kliknij, aby wyświetlić"
sensitive: "NSFW"
@ -643,6 +644,9 @@ backgroundColor: "Tło"
accentColor: "Akcent"
textColor: "Tekst"
value: "Wartość"
_email:
_follow:
title: "Zaobserwował(a) Cię"
_registry:
key: "Klucz"
keys: "Klucz"

View File

@ -97,6 +97,7 @@ cantRenote: "Это нельзя репостить."
cantReRenote: "Невозможно репостить репост."
quote: "Цитата"
pinnedNote: "Закреплённая заметка"
pinned: "Закрепить в профиле"
you: "Вы"
clickToShow: "Нажмите для просмотра"
sensitive: "Содержимое не для всех"
@ -437,6 +438,7 @@ signinWith: "Использовать {x} для входа"
signinFailed: "Невозможно войти в систему. Введенное вами имя пользователя или пароль неверны."
tapSecurityKey: "Нажмите на свой электронный ключ"
or: "или"
language: "Язык"
uiLanguage: "Язык интерфейса"
groupInvited: "Приглашение в группу"
aboutX: "Описание {x}"
@ -701,6 +703,12 @@ inUse: "Занято"
editCode: "Редактировать исходный текст"
apply: "Применить"
receiveAnnouncementFromInstance: "Получать оповещения с инстанса"
emailNotification: "Уведомления по электронной почте"
_email:
_follow:
title: "Новый подписчик"
_receiveFollowRequest:
title: "Новый запрос на подписку."
_plugin:
install: "Установка расширений"
installWarn: "Пожалуйста, не устанавливайте расширения, которым не доверяете."

View File

@ -1,7 +1,7 @@
---
_lang_: "Українська"
headlineMisskey: "Мережа об'єднана записами"
introMisskey: "Ласкаво просимо! Misskey - децентралізована служба мікроблогів з відкритим кодом.\nСтворюйте \"записи\", щоб поділитися тим, що відбувається, і розповісти всім про себе 📡\nЗа допомогою \"реакцій\" ви також можете швидко висловити свої почуття щодо записів інших 👍\nДавайте досліджувати новий світ 🚀"
introMisskey: "Ласкаво просимо! Misskey - децентралізована служба мікроблогів з відкритим кодом.\nСтворюйте \"нотатки\", щоб поділитися тим, що відбувається, і розповісти всім про себе 📡\nЗа допомогою \"реакцій\" ви також можете швидко висловити свої почуття щодо нотаток інших 👍\nДосліджуймо новий світ! 🚀"
monthAndDay: "{month}/{day}"
search: "Пошук"
notifications: "Сповіщення"
@ -97,6 +97,7 @@ cantRenote: "Неможливо поширити."
cantReRenote: "Поширення не можливо поширити."
quote: "Цитата"
pinnedNote: "Закріплений запис"
pinned: "Закріпити"
you: "Ви"
clickToShow: "Натисніть для перегляду"
sensitive: "NSFW"
@ -688,6 +689,9 @@ deleteConfirm: "Ви дійсно бажаєте це видалити?"
invalidValue: "Некоректне значення."
registry: "Реєстр"
closeAccount: "Закрити обліковий запис"
_email:
_follow:
title: "Новий підписник"
_registry:
key: "Ключ"
keys: "Ключі"
@ -978,6 +982,7 @@ _weekday:
friday: "П'ятниця"
saturday: "Субота"
_widgets:
memo: "Нагадування"
notifications: "Сповіщення"
timeline: "Стрічка"
calendar: "Календар"
@ -991,7 +996,10 @@ _widgets:
postForm: "Створення нотатки"
slideshow: "Слайд-шоу"
button: "Кнопка"
onlineUsers: "Користувачі онлайн"
jobQueue: "Черга завдань"
serverMetric: "Показники сервера "
aiscript: "Консоль AiScript"
_cw:
hide: "Сховати"
show: "Показати більше"
@ -999,10 +1007,13 @@ _cw:
files: "{count} файлів"
_poll:
noOnlyOneChoice: "Потрібні принаймні два варіанти."
choiceN: "Варіант {n}"
noMore: "Більше варіантів додати не можна"
canMultipleVote: "Можна вибрати кілька варіантів"
expiration: "Опитування закінчується"
infinite: "Ніколи"
at: "На даті..."
after: "Через..."
deadlineDate: "Дата закінчення"
deadlineTime: "г"
duration: "Тривалість"

View File

@ -97,6 +97,7 @@ cantRenote: "该帖子无法转发。"
cantReRenote: "转发无法被再次转发。"
quote: "引用"
pinnedNote: "已置顶的帖子"
pinned: "置顶"
you: "您"
clickToShow: "点击以显示"
sensitive: "敏感内容"
@ -437,6 +438,7 @@ signinWith: "以{x}登录"
signinFailed: "无法登录,请检查您的用户名和密码。"
tapSecurityKey: "轻触硬件安全密钥"
or: "或者"
language: "语言"
uiLanguage: "显示语言"
groupInvited: "群组招待"
aboutX: "关于 {x}"
@ -701,6 +703,12 @@ inUse: "已使用"
editCode: "编辑代码"
apply: "应用"
receiveAnnouncementFromInstance: "从实例接收通知"
emailNotification: "邮件通知"
_email:
_follow:
title: "你有新的关注者"
_receiveFollowRequest:
title: "收到关注请求"
_plugin:
install: "安装插件"
installWarn: "请不要安装不可信的插件。"

View File

@ -97,6 +97,7 @@ cantRenote: "無法轉發此貼文。"
cantReRenote: "無法轉發之前已經轉發過的內容"
quote: "引用"
pinnedNote: "已置頂的貼文"
pinned: "置頂"
you: "您"
clickToShow: "按一下以顯示"
sensitive: "敏感內容"
@ -163,6 +164,7 @@ storageUsage: "已使用容量"
charts: "圖表"
perHour: "每小時"
perDay: "每日"
stopActivityDelivery: "停止發送活動"
blockThisInstance: "封鎖此實例"
operations: "操作"
software: "軟體"
@ -582,6 +584,7 @@ channel: "頻道"
create: "新增"
notificationSetting: "通知設定"
notificationSettingDesc: "選擇顯示通知的類型"
useGlobalSetting: "使用全域設定"
other: "其他"
regenerateLoginToken: "再生登入權杖"
regenerateLoginTokenDescription: "再生用於登入的內部權杖。一般情況下是不需要這樣做的。一旦再生,所有裝置將會被登出。"
@ -674,6 +677,9 @@ newVersionOfClientAvailable: "新版本的用戶端可用。"
usageAmount: "使用量"
capacity: "容量"
inUse: "已使用"
_email:
_follow:
title: "您有新的追隨者"
_registry:
scope: "範圍"
key: "機碼"

View File

@ -0,0 +1,14 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class emailNotificationTypes1613155914446 implements MigrationInterface {
name = 'emailNotificationTypes1613155914446'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "emailNotificationTypes" jsonb NOT NULL DEFAULT '["follow","receiveFollowRequest","groupInvited"]'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "emailNotificationTypes"`);
}
}

View File

@ -0,0 +1,14 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class userLang1613181457597 implements MigrationInterface {
name = 'userLang1613181457597'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "lang" character varying(32)`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "lang"`);
}
}

View File

@ -0,0 +1,15 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class useBigintForDriveUsage1613503367223 implements MigrationInterface {
name = 'useBigintForDriveUsage1613503367223'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "driveUsage" TYPE bigint`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "driveUsage"`);
await queryRunner.query(`ALTER TABLE "instance" ADD "driveUsage" integer NOT NULL DEFAULT 0`);
}
}

View File

@ -1,7 +1,7 @@
{
"name": "misskey",
"author": "syuilo <syuilotan@yahoo.co.jp>",
"version": "12.69.0",
"version": "12.70.0",
"codename": "indigo",
"repository": {
"type": "git",
@ -114,10 +114,11 @@
"autobind-decorator": "2.4.0",
"autosize": "4.0.2",
"autwh": "0.1.0",
"aws-sdk": "2.839.0",
"aws-sdk": "2.840.0",
"bcryptjs": "2.4.3",
"blurhash": "1.1.3",
"bull": "3.20.0",
"broadcast-channel": "3.4.1",
"bull": "3.20.1",
"cafy": "15.2.1",
"cbor": "6.0.1",
"chalk": "4.1.0",
@ -127,7 +128,7 @@
"content-disposition": "0.5.3",
"core-js": "3.8.3",
"crc-32": "1.2.0",
"css-loader": "5.0.1",
"css-loader": "5.0.2",
"cssnano": "4.1.10",
"dateformat": "4.5.1",
"diskusage": "1.1.3",
@ -154,7 +155,7 @@
"http-proxy-agent": "4.0.1",
"http-signature": "1.3.5",
"https-proxy-agent": "5.0.0",
"idb-keyval": "5.0.1",
"idb-keyval": "5.0.2",
"insert-text-at-cursor": "0.3.0",
"is-root": "2.1.0",
"is-svg": "4.2.1",
@ -177,15 +178,15 @@
"langmap": "0.0.16",
"lookup-dns-cache": "2.1.0",
"markdown-it": "12.0.4",
"markdown-it-anchor": "7.0.1",
"markdown-it-anchor": "7.0.2",
"matter-js": "0.16.1",
"mocha": "8.2.1",
"mocha": "8.3.0",
"moji": "0.5.1",
"ms": "2.1.3",
"multer": "1.4.2",
"nested-property": "4.0.0",
"node-fetch": "2.6.1",
"nodemailer": "6.4.17",
"nodemailer": "6.4.18",
"object-assign-deep": "0.4.0",
"os-utils": "0.0.14",
"p-cancelable": "2.0.0",
@ -193,7 +194,7 @@
"parsimmon": "1.16.0",
"pg": "8.5.1",
"portscanner": "2.2.0",
"postcss": "8.2.4",
"postcss": "8.2.5",
"postcss-loader": "5.0.0",
"prismjs": "1.23.0",
"probe-image-size": "6.0.0",
@ -218,7 +219,7 @@
"rndstr": "1.0.0",
"s-age": "1.1.2",
"sass": "1.32.6",
"sass-loader": "11.0.0",
"sass-loader": "11.0.1",
"seedrandom": "3.0.5",
"sharp": "0.27.1",
"speakeasy": "2.0.0",
@ -233,12 +234,12 @@
"throttle-debounce": "3.0.1",
"tinycolor2": "1.4.2",
"tmp": "0.2.1",
"ts-loader": "8.0.15",
"ts-loader": "8.0.16",
"ts-node": "9.1.1",
"tslint": "6.1.3",
"tslint-sonarts": "1.9.0",
"typeorm": "0.2.30",
"typescript": "4.1.3",
"typescript": "4.1.5",
"ulid": "2.3.0",
"url-loader": "4.1.1",
"uuid": "8.3.2",
@ -253,7 +254,7 @@
"vue-style-loader": "4.1.2",
"vuedraggable": "4.0.1",
"web-push": "3.4.4",
"webpack": "5.21.1",
"webpack": "5.21.2",
"webpack-cli": "4.5.0",
"websocket": "1.0.33",
"ws": "7.4.3",

View File

@ -1,6 +1,7 @@
import { reactive } from 'vue';
import { apiUrl } from '@/config';
import { waiting } from '@/os';
import { unisonReload } from '@/scripts/unison-reload';
// TODO: 他のタブと永続化されたstateを同期
@ -62,6 +63,7 @@ export function updateAccount(data) {
for (const [key, value] of Object.entries(data)) {
$i[key] = value;
}
localStorage.setItem('account', JSON.stringify($i));
}
export function refreshAccount() {
@ -74,7 +76,7 @@ export async function login(token: Account['token']) {
const me = await fetchAccount(token);
localStorage.setItem('account', JSON.stringify(me));
addAccount(me.id, token);
location.reload();
unisonReload();
}
// このファイルに書きたくないけどここに書かないと何故かVeturが認識しない

View File

@ -99,7 +99,8 @@ import { faHeart, faFlag, faLaugh } from '@fortawesome/free-regular-svg-icons';
import MkModal from '@/components/ui/modal.vue';
import Particle from '@/components/particle.vue';
import * as os from '@/os';
import { isDeviceTouch } from '../scripts/is-device-touch';
import { isDeviceTouch } from '@/scripts/is-device-touch';
import { isMobile } from '@/scripts/is-mobile';
import { emojiCategories } from '@/instance';
export default defineComponent({
@ -322,7 +323,7 @@ export default defineComponent({
},
mounted() {
if (!os.isMobile) {
if (!isMobile && !isDeviceTouch) {
this.$refs.search.focus({
preventScroll: true
});

View File

@ -1,28 +1,11 @@
<template>
<FormGroup class="_formItem">
<template #label><slot></slot></template>
<div class="ztzhwixg _formItem" :class="{ inline, disabled }">
<div class="_formLabel"><slot></slot></div>
<div class="icon" ref="icon"><slot name="icon"></slot></div>
<div class="input _formPanel">
<div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div>
<input v-if="debounce" ref="inputEl"
v-debounce="500"
:type="type"
v-model.lazy="v"
:disabled="disabled"
:required="required"
:readonly="readonly"
:placeholder="placeholder"
:pattern="pattern"
:autocomplete="autocomplete"
:spellcheck="spellcheck"
:step="step"
@focus="focused = true"
@blur="focused = false"
@keydown="onKeydown($event)"
@input="onInput"
:list="id"
>
<input v-else ref="inputEl"
<input ref="inputEl"
:type="type"
v-model="v"
:disabled="disabled"
@ -44,20 +27,24 @@
</datalist>
<div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div>
</div>
<button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $ts.save }}</button>
<div class="_formCaption"><slot name="desc"></slot></div>
</div>
<template #caption><slot name="desc"></slot></template>
<FormButton v-if="manualSave && changed" @click="updated" primary><Fa :icon="faSave"/> {{ $ts.save }}</FormButton>
</FormGroup>
</template>
<script lang="ts">
import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue';
import debounce from 'v-debounce';
import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons';
import { faExclamationCircle, faSave } from '@fortawesome/free-solid-svg-icons';
import './form.scss';
import FormButton from './button.vue';
import FormGroup from './group.vue';
export default defineComponent({
directives: {
debounce
components: {
FormGroup,
FormButton,
},
props: {
value: {
@ -101,9 +88,6 @@ export default defineComponent({
step: {
required: false
},
debounce: {
required: false
},
datalist: {
type: Array,
required: false,
@ -113,9 +97,10 @@ export default defineComponent({
required: false,
default: false
},
save: {
type: Function,
manualSave: {
type: Boolean,
required: false,
default: false
},
},
emits: ['change', 'keydown', 'enter'],
@ -144,15 +129,22 @@ export default defineComponent({
}
};
const updated = () => {
changed.value = false;
if (type?.value === 'number') {
context.emit('update:value', parseFloat(v.value));
} else {
context.emit('update:value', v.value);
}
};
watch(value, newValue => {
v.value = newValue;
});
watch(v, newValue => {
if (type?.value === 'number') {
context.emit('update:value', parseFloat(newValue));
} else {
context.emit('update:value', newValue);
if (!props.manualSave) {
updated();
}
invalid.value = inputEl.value.validity.badInput;
@ -198,7 +190,8 @@ export default defineComponent({
focus,
onInput,
onKeydown,
faExclamationCircle,
updated,
faExclamationCircle, faSave,
};
},
});
@ -285,11 +278,6 @@ export default defineComponent({
}
}
> .save {
margin: 6px 0 0 0;
font-size: 0.8em;
}
&.inline {
display: inline-block;
margin: 0;

View File

@ -1,9 +1,10 @@
<template>
<FormGroup class="_formItem">
<template #label><slot></slot></template>
<div class="rivhosbp _formItem" :class="{ tall, pre }">
<div class="_formLabel"><slot></slot></div>
<div class="input _formPanel">
<textarea ref="input" :class="{ code, _monospace: code }"
:value="value"
v-model="v"
:required="required"
:readonly="readonly"
:pattern="pattern"
@ -14,16 +15,25 @@
@blur="focused = false"
></textarea>
</div>
<button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $ts.save }}</button>
<div class="_formCaption"><slot name="desc"></slot></div>
</div>
<template #caption><slot name="desc"></slot></template>
<FormButton v-if="manualSave && changed" @click="updated" primary><Fa :icon="faSave"/> {{ $ts.save }}</FormButton>
</FormGroup>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { defineComponent, ref, toRefs, watch } from 'vue';
import { faSave } from '@fortawesome/free-solid-svg-icons';
import './form.scss';
import FormButton from './button.vue';
import FormGroup from './group.vue';
export default defineComponent({
components: {
FormGroup,
FormButton,
},
props: {
value: {
required: false
@ -58,24 +68,46 @@ export default defineComponent({
required: false,
default: false
},
save: {
type: Function,
manualSave: {
type: Boolean,
required: false,
default: false
},
},
data() {
setup(props, context) {
const { value } = toRefs(props);
const v = ref(value.value);
const changed = ref(false);
const inputEl = ref(null);
const focus = () => inputEl.value.focus();
const onInput = (ev) => {
changed.value = true;
context.emit('change', ev);
};
const updated = () => {
changed.value = false;
context.emit('update:value', v.value);
};
watch(value, newValue => {
v.value = newValue;
});
watch(v, newValue => {
if (!props.manualSave) {
updated();
}
});
return {
changed: false,
}
},
methods: {
focus() {
this.$refs.input.focus();
},
onInput(ev) {
this.changed = true;
this.$emit('update:value', ev.target.value);
}
v,
updated,
changed,
focus,
onInput,
faSave,
};
}
});
</script>
@ -112,11 +144,6 @@ export default defineComponent({
}
}
> .save {
margin: 6px 0 0 0;
font-size: 0.8em;
}
&.tall {
> .input {
> textarea {

View File

@ -1,8 +1,8 @@
<template>
<span class="eiwwqkts" :class="{ cat }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick">
<span class="eiwwqkts _noSelect" :class="{ cat }" :title="acct(user)" v-if="disableLink" v-user-preview="disablePreview ? undefined : user.id" @click="onClick">
<img class="inner" :src="url" decoding="async"/>
</span>
<MkA class="eiwwqkts" :class="{ cat }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id">
<MkA class="eiwwqkts _noSelect" :class="{ cat }" :to="userPage(user)" :title="acct(user)" :target="target" v-else v-user-preview="disablePreview ? undefined : user.id">
<img class="inner" :src="url" decoding="async"/>
</MkA>
</template>

View File

@ -1,6 +1,6 @@
<template>
<div
class="note _panel"
class="tkcbzcuz _panel"
v-if="!muted"
v-show="!isDeleted"
:tabindex="!isDeleted ? '-1' : null"
@ -858,7 +858,7 @@ export default defineComponent({
</script>
<style lang="scss" scoped>
.note {
.tkcbzcuz {
position: relative;
transition: box-shadow 0.1s ease;
overflow: hidden;

View File

@ -8,7 +8,7 @@
<MkError v-if="error" @retry="init()"/>
<div v-show="more && reversed" style="margin-bottom: var(--margin);">
<button class="_loadMore" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
<button class="_buttonPrimary" @click="fetchMoreFeature" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template>
</button>
@ -19,7 +19,7 @@
</XList>
<div v-show="more && !reversed" style="margin-top: var(--margin);">
<button class="_loadMore" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
<button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template>
</button>

View File

@ -1,11 +1,11 @@
<template>
<div class="mfcuwfyp">
<div class="mfcuwfyp _noGap_">
<XList class="notifications" :items="items" v-slot="{ item: notification }">
<XNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :note="notification.note" @update:note="noteUpdated(notification.note, $event)" :key="notification.id"/>
<XNotification v-else :notification="notification" :with-time="true" :full="true" class="_panel notification" :key="notification.id"/>
</XList>
<button class="_loadMore" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
<button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" v-show="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template>
</button>

View File

@ -69,6 +69,7 @@ import { noteVisibilities } from '../../types';
import * as os from '@/os';
import { selectFile } from '@/scripts/select-file';
import { notePostInterruptors, postFormActions } from '@/store';
import { isMobile } from '@/scripts/is-mobile';
export default defineComponent({
components: {
@ -554,7 +555,7 @@ export default defineComponent({
localOnly: this.localOnly,
visibility: this.visibility,
visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined,
viaMobile: os.isMobile
viaMobile: isMobile
};
// plugin

View File

@ -51,7 +51,7 @@ export default defineComponent({
text: '',
flag: true,
radio: 'misskey',
mfm: `Hello world! This is an @example mention. BTW you are @${this.$i.username}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.`
mfm: `Hello world! This is an @example mention. BTW you are @${this.$i ? this.$i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.`
}
},

View File

@ -55,6 +55,14 @@ import { sidebarDef } from '@/sidebar';
import { getAccounts, addAccount, login } from '@/account';
export default defineComponent({
props: {
defaultHidden: {
type: Boolean,
required: false,
default: false,
}
},
data() {
return {
host: host,
@ -63,7 +71,7 @@ export default defineComponent({
connection: null,
menuDef: sidebarDef,
iconOnly: false,
hidden: false,
hidden: this.defaultHidden,
faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer, faProjectDiagram
};
},
@ -112,7 +120,9 @@ export default defineComponent({
methods: {
calcViewState() {
this.iconOnly = (window.innerWidth <= 1279) || (this.$store.state.sidebarDisplay === 'icon');
if (!this.defaultHidden) {
this.hidden = (window.innerWidth <= 650);
}
},
show() {
@ -128,13 +138,19 @@ export default defineComponent({
},
async openAccountMenu(ev) {
const storedAccounts = getAccounts();
const accounts = (await os.api('users/show', { userIds: storedAccounts.map(x => x.id) })).filter(x => x.id !== this.$i.id);
const storedAccounts = getAccounts().filter(x => x.id !== this.$i.id);
const accountsPromise = os.api('users/show', { userIds: storedAccounts.map(x => x.id) });
const accountItems = accounts.map(account => ({
const accountItemPromises = storedAccounts.map(a => new Promise(res => {
accountsPromise.then(accounts => {
const account = accounts.find(x => x.id === a.id);
if (account == null) return res(null);
res({
type: 'user',
user: account,
action: () => { this.switchAccount(account); }
});
});
}));
os.modalMenu([...[{
@ -142,7 +158,7 @@ export default defineComponent({
text: this.$ts.profile,
to: `/@${ this.$i.username }`,
avatar: this.$i,
}, null, ...accountItems, {
}, null, ...accountItemPromises, {
icon: faPlus,
text: this.$ts.addAcount,
action: () => {

View File

@ -98,11 +98,11 @@ export default defineComponent({
}
} else {
if (left + width - window.pageXOffset > window.innerWidth) {
left = window.innerWidth - width + window.pageXOffset;
left = window.innerWidth - width + window.pageXOffset - 1;
}
if (top + height - window.pageYOffset > window.innerHeight) {
top = window.innerHeight - height + window.pageYOffset;
top = window.innerHeight - height + window.pageYOffset - 1;
}
}

View File

@ -1,6 +1,6 @@
<template>
<transition name="zoom-in-top" appear @after-leave="$emit('closed')">
<div class="buebdbiu _acrylic _shadow" v-if="showing">
<transition name="tooltip" appear @after-leave="$emit('closed')">
<div class="buebdbiu _acrylic _shadow" v-show="showing" ref="content">
<slot>{{ text }}</slot>
</div>
</transition>
@ -35,19 +35,43 @@ export default defineComponent({
const rect = this.source.getBoundingClientRect();
let x = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
let y = rect.top + window.pageYOffset + this.source.offsetHeight;
const contentWidth = this.$refs.content.offsetWidth;
const contentHeight = this.$refs.content.offsetHeight;
x -= (this.$el.offsetWidth / 2);
let left = rect.left + window.pageXOffset + (this.source.offsetWidth / 2);
let top = rect.top + window.pageYOffset + this.source.offsetHeight;
this.$el.style.left = x + 'px';
this.$el.style.top = y + 'px';
left -= (this.$el.offsetWidth / 2);
if (left + contentWidth - window.pageXOffset > window.innerWidth) {
left = window.innerWidth - contentWidth + window.pageXOffset - 1;
}
if (top + contentHeight - window.pageYOffset > window.innerHeight) {
top = rect.top + window.pageYOffset - contentHeight;
this.$refs.content.style.transformOrigin = 'center bottom';
}
this.$el.style.left = left + 'px';
this.$el.style.top = top + 'px';
});
},
})
</script>
<style lang="scss" scoped>
.tooltip-enter-active,
.tooltip-leave-active {
opacity: 1;
transform: scale(1);
transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1), opacity 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.tooltip-enter-from,
.tooltip-leave-active {
opacity: 0;
transform: scale(0.75);
}
.buebdbiu {
position: absolute;
z-index: 11000;
@ -57,6 +81,6 @@ export default defineComponent({
text-align: center;
border-radius: 4px;
pointer-events: none;
transform-origin: center -16px;
transform-origin: center top;
}
</style>

View File

@ -0,0 +1,153 @@
<template>
<div class="vjoppmmu">
<template v-if="edit">
<header>
<MkSelect v-model:value="widgetAdderSelected" style="margin-bottom: var(--margin)">
<template #label>{{ $ts.selectWidget }}</template>
<option v-for="widget in widgetDefs" :value="widget" :key="widget">{{ $t(`_widgets.${widget}`) }}</option>
</MkSelect>
<MkButton inline @click="addWidget" primary><Fa :icon="faPlus"/> {{ $ts.add }}</MkButton>
<MkButton inline @click="$emit('exit')">{{ $ts.close }}</MkButton>
</header>
<XDraggable
v-model="_widgets"
item-key="id"
animation="150"
>
<template #item="{element}">
<div class="customize-container">
<button class="config _button" @click.prevent.stop="configWidget(element.id)"><Fa :icon="faCog"/></button>
<button class="remove _button" @click.prevent.stop="removeWidget(element)"><Fa :icon="faTimes"/></button>
<component :is="`mkw-${element.name}`" :widget="element" :setting-callback="setting => settings[element.id] = setting" :column="column" @updateProps="updateWidget(element.id, $event)"/>
</div>
</template>
</XDraggable>
</template>
<component v-else class="widget" v-for="widget in widgets" :is="`mkw-${widget.name}`" :key="widget.id" :widget="widget" :column="column" @updateProps="updateWidget(widget.id, $event)"/>
</div>
</template>
<script lang="ts">
import { defineComponent, defineAsyncComponent } from 'vue';
import { v4 as uuid } from 'uuid';
import { faTimes, faCog, faPlus } from '@fortawesome/free-solid-svg-icons';
import MkSelect from '@/components/ui/select.vue';
import MkButton from '@/components/ui/button.vue';
import { widgets as widgetDefs } from '@/widgets';
export default defineComponent({
components: {
XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
MkSelect,
MkButton,
},
props: {
widgets: {
required: true,
},
edit: {
type: Boolean,
required: true,
},
},
emits: ['updateWidgets', 'addWidget', 'removeWidget', 'updateWidget', 'exit'],
data() {
return {
widgetAdderSelected: null,
widgetDefs,
settings: {},
faTimes, faPlus, faCog
};
},
computed: {
_widgets: {
get() {
return this.widgets;
},
set(value) {
this.$emit('updateWidgets', value);
}
}
},
methods: {
configWidget(id) {
this.settings[id]();
},
addWidget() {
if (this.widgetAdderSelected == null) return;
this.$emit('addWidget', {
name: this.widgetAdderSelected,
id: uuid(),
data: {}
});
this.widgetAdderSelected = null;
},
removeWidget(widget) {
this.$emit('removeWidget', widget);
},
updateWidget(id, data) {
this.$emit('updateWidget', { id, data });
},
}
});
</script>
<style lang="scss" scoped>
.vjoppmmu {
> header {
margin: 16px 0;
> * {
width: 100%;
padding: 4px;
}
}
> .widget, .customize-container {
margin: var(--margin) 0;
&:first-of-type {
margin-top: 0;
}
}
.customize-container {
position: relative;
cursor: move;
> *:not(.remove):not(.config) {
pointer-events: none;
}
> .config,
> .remove {
position: absolute;
z-index: 10000;
top: 8px;
width: 32px;
height: 32px;
color: #fff;
background: rgba(#000, 0.7);
border-radius: 4px;
}
> .config {
right: 8px + 8px + 32px;
}
> .remove {
right: 8px;
}
}
}
</style>

View File

@ -3,12 +3,22 @@ import { getScrollContainer, getScrollPosition } from '@/scripts/scroll';
export default {
mounted(src, binding, vn) {
const ro = new ResizeObserver((entries, observer) => {
const pos = getScrollPosition(src);
const container = getScrollContainer(src);
if (binding.value === false) return;
let isBottom = true;
const container = getScrollContainer(src)!;
container.addEventListener('scroll', () => {
const pos = getScrollPosition(container);
const viewHeight = container.clientHeight;
const height = container.scrollHeight;
if (pos + viewHeight > height - 32) {
isBottom = (pos + viewHeight > height - 32);
}, { passive: true });
container.scrollTop = container.scrollHeight;
const ro = new ResizeObserver((entries, observer) => {
if (isBottom) {
const height = container.scrollHeight;
container.scrollTop = height;
}
});
@ -20,6 +30,6 @@ export default {
},
unmounted(src, binding, vn) {
src._ro_.unobserve(src);
if (src._ro_) src._ro_.unobserve(src);
}
} as Directive;

View File

@ -4,6 +4,7 @@ import { popup } from '@/os';
const start = isDeviceTouch ? 'touchstart' : 'mouseover';
const end = isDeviceTouch ? 'touchend' : 'mouseleave';
const delay = 100;
export default {
mounted(el: HTMLElement, binding, vn) {
@ -47,13 +48,13 @@ export default {
el.addEventListener(start, () => {
clearTimeout(self.showTimer);
clearTimeout(self.hideTimer);
self.showTimer = setTimeout(show, 300);
self.showTimer = setTimeout(show, delay);
}, { passive: true });
el.addEventListener(end, () => {
clearTimeout(self.showTimer);
clearTimeout(self.hideTimer);
self.hideTimer = setTimeout(self.close, 300);
self.hideTimer = setTimeout(self.close, delay);
}, { passive: true });
el.addEventListener('click', () => {

View File

@ -1,6 +1,6 @@
import { markRaw } from 'vue';
import { locale } from '@/config';
import { I18n } from '@/scripts/i18n';
import { I18n } from '../misc/i18n';
export const i18n = markRaw(new I18n(locale));

View File

@ -49,15 +49,17 @@ import { router } from '@/router';
import { applyTheme } from '@/scripts/theme';
import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
import { i18n } from '@/i18n';
import { stream, isMobile, dialog, post } from '@/os';
import { stream, dialog, post } from '@/os';
import * as sound from '@/scripts/sound';
import { $i, refreshAccount, login, updateAccount, signout } from '@/account';
import { defaultStore, ColdDeviceStorage } from '@/store';
import { fetchInstance, instance } from '@/instance';
import { makeHotkey } from './scripts/hotkey';
import { search } from './scripts/search';
import { getThemes } from './theme-store';
import { initializeSw } from './scripts/initialize-sw';
import { makeHotkey } from '@/scripts/hotkey';
import { search } from '@/scripts/search';
import { isMobile } from '@/scripts/is-mobile';
import { getThemes } from '@/theme-store';
import { initializeSw } from '@/scripts/initialize-sw';
import { reloadChannel } from '@/scripts/unison-reload';
console.info(`Misskey v${version}`);
@ -105,6 +107,9 @@ if (defaultStore.state.reportError && !_DEV_) {
// タッチデバイスでCSSの:hoverを機能させる
document.addEventListener('touchend', () => {}, { passive: true });
// 一斉リロード
reloadChannel.addEventListener('message', () => location.reload());
//#region SEE: https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
// TODO: いつの日にか消したい
const vh = window.innerHeight * 0.01;
@ -182,6 +187,7 @@ const app = createApp(await (
!$i ? import('@/ui/visitor.vue') :
ui === 'deck' ? import('@/ui/deck.vue') :
ui === 'desktop' ? import('@/ui/desktop.vue') :
ui === 'chat' ? import('@/ui/chat/index.vue') :
import('@/ui/default.vue')
).then(x => x.default));

View File

@ -9,9 +9,6 @@ import { resolve } from '@/router';
import { $i } from '@/account';
import { defaultStore } from '@/store';
const ua = navigator.userAgent.toLowerCase();
export const isMobile = /mobile|iphone|ipad|android/.test(ua);
export const stream = markRaw(new Stream());
export const pendingApiRequestsCount = ref(0);

View File

@ -70,7 +70,8 @@ export default defineComponent({
async run() {
this.logs = [];
const aiscript = new AiScript(createAiScriptEnv({
storageKey: 'scratchpad'
storageKey: 'scratchpad',
token: this.$i?.token,
}), {
in: (q) => {
return new Promise(ok => {

View File

@ -28,6 +28,7 @@ export default defineComponent({
limit: 10,
params: () => ({
query: this.$route.query.q,
channelId: this.$route.query.channel,
})
},
};

View File

@ -40,6 +40,7 @@ import FormBase from '@/components/form/base.vue';
import FormGroup from '@/components/form/group.vue';
import { deckStore } from '@/ui/deck/deck-store';
import * as os from '@/os';
import { unisonReload } from '@/scripts/unison-reload';
export default defineComponent({
components: {
@ -81,7 +82,7 @@ export default defineComponent({
});
if (canceled) return;
location.reload();
unisonReload();
}
},
@ -99,7 +100,7 @@ export default defineComponent({
});
if (canceled) return;
this.profile = name;
location.reload();
unisonReload();
}
}
});

View File

@ -0,0 +1,90 @@
<template>
<FormBase>
<FormGroup>
<FormSwitch v-model:value="mention">
{{ $ts._notification._types.mention }}
</FormSwitch>
<FormSwitch v-model:value="reply">
{{ $ts._notification._types.reply }}
</FormSwitch>
<FormSwitch v-model:value="quote">
{{ $ts._notification._types.quote }}
</FormSwitch>
<FormSwitch v-model:value="follow">
{{ $ts._notification._types.follow }}
</FormSwitch>
<FormSwitch v-model:value="receiveFollowRequest">
{{ $ts._notification._types.receiveFollowRequest }}
</FormSwitch>
<FormSwitch v-model:value="groupInvited">
{{ $ts._notification._types.groupInvited }}
</FormSwitch>
</FormGroup>
</FormBase>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { faCog } from '@fortawesome/free-solid-svg-icons';
import { faBell, faEnvelope } from '@fortawesome/free-regular-svg-icons';
import FormButton from '@/components/form/button.vue';
import FormSwitch from '@/components/form/switch.vue';
import FormBase from '@/components/form/base.vue';
import FormGroup from '@/components/form/group.vue';
import * as os from '@/os';
export default defineComponent({
components: {
FormBase,
FormSwitch,
FormButton,
FormGroup,
},
emits: ['info'],
data() {
return {
INFO: {
title: this.$ts.emailNotification,
icon: faEnvelope
},
mention: this.$i.emailNotificationTypes.includes('mention'),
reply: this.$i.emailNotificationTypes.includes('reply'),
quote: this.$i.emailNotificationTypes.includes('quote'),
follow: this.$i.emailNotificationTypes.includes('follow'),
receiveFollowRequest: this.$i.emailNotificationTypes.includes('receiveFollowRequest'),
groupInvited: this.$i.emailNotificationTypes.includes('groupInvited'),
}
},
created() {
this.$watch('mention', this.save);
this.$watch('reply', this.save);
this.$watch('quote', this.save);
this.$watch('follow', this.save);
this.$watch('receiveFollowRequest', this.save);
this.$watch('groupInvited', this.save);
},
mounted() {
this.$emit('info', this.INFO);
},
methods: {
save() {
os.api('i/update', {
emailNotificationTypes: [
...[this.mention ? 'mention' : null],
...[this.reply ? 'reply' : null],
...[this.quote ? 'quote' : null],
...[this.follow ? 'follow' : null],
...[this.receiveFollowRequest ? 'receiveFollowRequest' : null],
...[this.groupInvited ? 'groupInvited' : null],
].filter(x => x != null)
});
}
}
});
</script>

View File

@ -9,6 +9,11 @@
</FormLink>
</FormGroup>
<FormLink to="/settings/email/notification">
<template #icon><Fa :icon="faBell"/></template>
{{ $ts.emailNotification }}
</FormLink>
<FormSwitch :value="$i.receiveAnnouncementEmail" @update:value="onChangeReceiveAnnouncementEmail">
{{ $ts.receiveAnnouncementFromInstance }}
</FormSwitch>
@ -43,7 +48,7 @@ export default defineComponent({
title: this.$ts.email,
icon: faEnvelope
},
faCog, faExclamationTriangle, faCheck
faCog, faExclamationTriangle, faCheck, faBell
}
},

View File

@ -96,6 +96,7 @@ import { langs } from '@/config';
import { defaultStore } from '@/store';
import { ColdDeviceStorage } from '@/store';
import * as os from '@/os';
import { unisonReload } from '@/scripts/unison-reload';
export default defineComponent({
components: {
@ -200,7 +201,7 @@ export default defineComponent({
});
if (canceled) return;
location.reload();
unisonReload();
}
}
});

View File

@ -52,6 +52,7 @@ import FormBase from '@/components/form/base.vue';
import FormButton from '@/components/form/button.vue';
import { scroll } from '@/scripts/scroll';
import { signout } from '@/account';
import { unisonReload } from '@/scripts/unison-reload';
export default defineComponent({
components: {
@ -99,6 +100,7 @@ export default defineComponent({
case 'general': return defineAsyncComponent(() => import('./general.vue'));
case 'email': return defineAsyncComponent(() => import('./email.vue'));
case 'email/address': return defineAsyncComponent(() => import('./email-address.vue'));
case 'email/notification': return defineAsyncComponent(() => import('./email-notification.vue'));
case 'theme': return defineAsyncComponent(() => import('./theme.vue'));
case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue'));
case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue'));
@ -159,7 +161,7 @@ export default defineComponent({
clear: () => {
localStorage.removeItem('locale');
localStorage.removeItem('theme');
location.reload();
unisonReload();
},
faPalette, faPlug, faUser, faListUl, faLock, faLaugh, faCommentSlash, faMusic, faBell, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey, faBoxes, faEnvelope, faCloud,
};

View File

@ -40,6 +40,7 @@ import * as os from '@/os';
import { debug } from '@/config';
import { defaultStore } from '@/store';
import { signout } from '@/account';
import { unisonReload } from '@/scripts/unison-reload';
export default defineComponent({
components: {
@ -76,7 +77,7 @@ export default defineComponent({
changeDebug(v) {
console.log(v);
localStorage.setItem('debug', v.toString());
location.reload();
unisonReload();
},
onChangeInjectFeaturedNote(v) {

View File

@ -1,6 +1,6 @@
<template>
<FormBase>
<MkInfo warn>{{ $ts.pluginInstallWarn }}</MkInfo>
<MkInfo warn>{{ $ts._plugin.installWarn }}</MkInfo>
<FormGroup>
<FormTextarea v-model:value="code" tall>
@ -28,6 +28,7 @@ import FormButton from '@/components/form/button.vue';
import MkInfo from '@/components/ui/info.vue';
import * as os from '@/os';
import { ColdDeviceStorage } from '@/store';
import { unisonReload } from '@/scripts/unison-reload';
export default defineComponent({
components: {
@ -138,7 +139,7 @@ export default defineComponent({
os.success();
this.$nextTick(() => {
location.reload();
unisonReload();
});
},
}

View File

@ -76,7 +76,7 @@ export default defineComponent({
ColdDeviceStorage.set('plugins', this.plugins.filter(x => x.id !== plugin.id));
os.success();
this.$nextTick(() => {
location.reload();
unisonReload();
});
},

View File

@ -8,25 +8,30 @@
<FormButton @click="changeBanner" primary>{{ $ts._profile.changeBanner }}</FormButton>
</FormGroup>
<FormInput v-model:value="name" :max="30">
<FormInput v-model:value="name" :max="30" manual-save>
<span>{{ $ts._profile.name }}</span>
</FormInput>
<FormTextarea v-model:value="description" :max="500">
<FormTextarea v-model:value="description" :max="500" tall manual-save>
<span>{{ $ts._profile.description }}</span>
<template #desc>{{ $ts._profile.youCanIncludeHashtags }}</template>
</FormTextarea>
<FormInput v-model:value="location">
<FormInput v-model:value="location" manual-save>
<span>{{ $ts.location }}</span>
<template #prefix><Fa :icon="faMapMarkerAlt"/></template>
</FormInput>
<FormInput v-model:value="birthday" type="date">
<FormInput v-model:value="birthday" type="date" manual-save>
<span>{{ $ts.birthday }}</span>
<template #prefix><Fa :icon="faBirthdayCake"/></template>
</FormInput>
<FormSelect v-model:value="lang">
<template #label>{{ $ts.language }}</template>
<option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
</FormSelect>
<FormGroup>
<FormButton @click="editMetadata" primary>{{ $ts._profile.metadataEdit }}</FormButton>
<template #caption>{{ $ts._profile.metadataDescription }}</template>
@ -37,8 +42,6 @@
<FormSwitch v-model:value="isBot">{{ $ts.flagAsBot }}<template #desc>{{ $ts.flagAsBotDescription }}</template></FormSwitch>
<FormSwitch v-model:value="alwaysMarkNsfw">{{ $ts.alwaysMarkSensitive }}</FormSwitch>
<FormButton @click="save(true)" primary><Fa :icon="faSave"/> {{ $ts.save }}</FormButton>
</FormBase>
</template>
@ -50,10 +53,10 @@ import FormButton from '@/components/form/button.vue';
import FormInput from '@/components/form/input.vue';
import FormTextarea from '@/components/form/textarea.vue';
import FormSwitch from '@/components/form/switch.vue';
import FormTuple from '@/components/form/tuple.vue';
import FormSelect from '@/components/form/select.vue';
import FormBase from '@/components/form/base.vue';
import FormGroup from '@/components/form/group.vue';
import { host } from '@/config';
import { host, langs } from '@/config';
import { selectFile } from '@/scripts/select-file';
import * as os from '@/os';
@ -63,7 +66,7 @@ export default defineComponent({
FormInput,
FormTextarea,
FormSwitch,
FormTuple,
FormSelect,
FormBase,
FormGroup,
},
@ -77,9 +80,11 @@ export default defineComponent({
icon: faUser
},
host,
langs,
name: null,
description: null,
birthday: null,
lang: null,
location: null,
fieldName0: null,
fieldValue0: null,
@ -104,6 +109,7 @@ export default defineComponent({
this.description = this.$i.description;
this.location = this.$i.location;
this.birthday = this.$i.birthday;
this.lang = this.$i.lang;
this.avatarId = this.$i.avatarId;
this.bannerId = this.$i.bannerId;
this.isBot = this.$i.isBot;
@ -118,6 +124,15 @@ export default defineComponent({
this.fieldValue2 = this.$i.fields[2] ? this.$i.fields[2].value : null;
this.fieldName3 = this.$i.fields[3] ? this.$i.fields[3].name : null;
this.fieldValue3 = this.$i.fields[3] ? this.$i.fields[3].value : null;
this.$watch('name', this.save);
this.$watch('description', this.save);
this.$watch('location', this.save);
this.$watch('birthday', this.save);
this.$watch('lang', this.save);
this.$watch('isBot', this.save);
this.$watch('isCat', this.save);
this.$watch('alwaysMarkNsfw', this.save);
},
mounted() {
@ -214,14 +229,15 @@ export default defineComponent({
});
},
save(notify) {
save() {
this.saving = true;
os.api('i/update', {
os.apiWithDialog('i/update', {
name: this.name || null,
description: this.description || null,
location: this.location || null,
birthday: this.birthday || null,
lang: this.lang || null,
isBot: !!this.isBot,
isCat: !!this.isCat,
alwaysMarkNsfw: !!this.alwaysMarkNsfw,
@ -231,16 +247,8 @@ export default defineComponent({
this.$i.avatarUrl = i.avatarUrl;
this.$i.bannerId = i.bannerId;
this.$i.bannerUrl = i.bannerUrl;
if (notify) {
os.success();
}
}).catch(err => {
this.saving = false;
os.dialog({
type: 'error',
text: err.id
});
});
},
}

View File

@ -0,0 +1,2 @@
const ua = navigator.userAgent.toLowerCase();
export const isMobile = /mobile|iphone|ipad|android/.test(ua);

View File

@ -1,9 +1,11 @@
import { markRaw } from 'vue';
import * as os from '@/os';
import { onScrollTop, isTopVisible } from './scroll';
import { onScrollTop, isTopVisible, getScrollPosition, getScrollContainer } from './scroll';
const SECOND_FETCH_LIMIT = 30;
// reversed: items 配列の中身を逆順にする(新しい方が最後)
export default (opts) => ({
emits: ['queue'],
@ -122,10 +124,41 @@ export default (opts) => ({
limit: SECOND_FETCH_LIMIT + 1,
...(this.pagination.offsetMode ? {
offset: this.offset,
} : this.pagination.reversed ? {
sinceId: this.items[0].id,
} : {
untilId: this.items[this.items.length - 1].id,
untilId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
}),
}).then(items => {
for (const item of items) {
markRaw(item);
}
if (items.length > SECOND_FETCH_LIMIT) {
items.pop();
this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
this.more = true;
} else {
this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
this.more = false;
}
this.offset += items.length;
this.moreFetching = false;
}, e => {
this.moreFetching = false;
});
},
async fetchMoreFeature() {
if (!this.more || this.fetching || this.moreFetching || this.items.length === 0) return;
this.moreFetching = true;
let params = typeof this.pagination.params === 'function' ? this.pagination.params(false) : this.pagination.params;
if (params && params.then) params = await params;
const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
await os.api(endpoint, {
...params,
limit: SECOND_FETCH_LIMIT + 1,
...(this.pagination.offsetMode ? {
offset: this.offset,
} : {
sinceId: this.pagination.reversed ? this.items[0].id : this.items[this.items.length - 1].id,
}),
}).then(items => {
for (const item of items) {
@ -147,6 +180,24 @@ export default (opts) => ({
},
prepend(item) {
if (this.pagination.reversed) {
const container = getScrollContainer(this.$el);
const pos = getScrollPosition(this.$el);
const viewHeight = container.clientHeight;
const height = container.scrollHeight;
const isBottom = (pos + viewHeight > height - 32);
if (isBottom) {
// オーバーフローしたら古いアイテムは捨てる
if (this.items.length >= opts.displayLimit) {
this.items = this.items.slice(-opts.displayLimit);
this.more = true;
}
} else {
}
this.items.push(item);
// TODO
} else {
const isTop = this.isBackTop || (document.body.contains(this.$el) && isTopVisible(this.$el));
if (isTop) {
@ -167,6 +218,7 @@ export default (opts) => ({
this.queue = [];
});
}
}
},
append(item) {

View File

@ -54,6 +54,14 @@ export function scroll(el: Element, top: number) {
}
}
export function scrollToTop(el: Element) {
scroll(el, 0);
}
export function scrollToBottom(el: Element) {
scroll(el, 99999); // TODO: ちゃんと計算する
}
export function isBottom(el: Element, asobi = 0) {
const container = getScrollContainer(el);
const current = container

View File

@ -0,0 +1,10 @@
// SafariがBroadcastChannel未実装なのでライブラリを使う
import { BroadcastChannel } from 'broadcast-channel';
export const reloadChannel = new BroadcastChannel<'reload'>('reload');
// BroadcastChannelを用いて、クライアントが一斉にreloadするようにします。
export function unisonReload() {
reloadChannel.postMessage('reload');
location.reload();
}

View File

@ -5,6 +5,7 @@ import { search } from '@/scripts/search';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { $i } from './account';
import { unisonReload } from '@/scripts/unison-reload';
export const sidebarDef = {
notifications: {
@ -133,19 +134,25 @@ export const sidebarDef = {
text: i18n.locale.default,
action: () => {
localStorage.setItem('ui', 'default');
location.reload();
unisonReload();
}
}, {
text: i18n.locale.deck,
action: () => {
localStorage.setItem('ui', 'deck');
location.reload();
unisonReload();
}
}, {
text: 'Chat (β)',
action: () => {
localStorage.setItem('ui', 'chat');
unisonReload();
}
}, {
text: i18n.locale.desktop + ' (β)',
action: () => {
localStorage.setItem('ui', 'desktop');
location.reload();
unisonReload();
}
}], ev.currentTarget || ev.target);
},

View File

@ -308,13 +308,6 @@ hr {
box-shadow: none;
}
._loadMore {
@extend ._panel;
@extend ._button;
width: 100%;
padding: 12px 0;
}
._borderButton {
@extend ._button;
display: block;
@ -495,19 +488,6 @@ hr {
transform: scale(0.9);
}
.zoom-in-top-enter-active,
.zoom-in-top-leave-active {
opacity: 1;
transform: scaleY(1);
transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1);
transform-origin: center top;
}
.zoom-in-top-enter-from,
.zoom-in-top-leave-active {
opacity: 0;
transform: scaleY(0);
}
@keyframes blink {
0% { opacity: 1; transform: scale(1); }
30% { opacity: 1; transform: scale(1); }

View File

@ -5,12 +5,11 @@ declare var self: ServiceWorkerGlobalScope;
import { get, set } from 'idb-keyval';
import composeNotification from '@/sw/compose-notification';
import { I18n } from '@/scripts/i18n';
import { I18n } from '../../misc/i18n';
//#region Variables
const version = _VERSION_;
const cacheName = `mk-cache-${version}`;
const apiUrl = `${location.origin}/api/`;
let lang: string;
let i18n: I18n<any>;
@ -27,15 +26,7 @@ get('lang').then(async prelang => {
//#region Lifecycle: Install
self.addEventListener('install', ev => {
ev.waitUntil(
caches.open(cacheName)
.then(cache => {
return cache.addAll([
`/?v=${version}`
]);
})
.then(() => self.skipWaiting())
);
self.skipWaiting();
});
//#endregion
@ -53,19 +44,9 @@ self.addEventListener('activate', ev => {
});
//#endregion
// TODO: 消せるかも ref. https://github.com/syuilo/misskey/pull/7108#issuecomment-774573666
//#region When: Fetching
self.addEventListener('fetch', ev => {
if (ev.request.method !== 'GET' || ev.request.url.startsWith(apiUrl)) return;
ev.respondWith(
caches.match(ev.request)
.then(response => {
return response || fetch(ev.request);
})
.catch(() => {
return caches.match(`/?v=${version}`);
})
);
// Nothing to do
});
//#endregion

View File

@ -16,6 +16,8 @@
bg: '#000',
acrylicBg: ':alpha<0.5<@bg',
fg: '#dadada',
fgTransparentWeak: ':alpha<0.75<@fg',
fgTransparent: ':alpha<0.5<@fg',
fgHighlighted: ':lighten<3<@fg',
divider: 'rgba(255, 255, 255, 0.1)',
indicator: '@accent',
@ -77,5 +79,6 @@
X14: ':alpha<0.5<@navBg',
X15: ':alpha<0<@panel',
X16: ':alpha<0.7<@panel',
X17: ':alpha<0.8<@bg',
},
}

View File

@ -16,6 +16,8 @@
bg: '#fff',
acrylicBg: ':alpha<0.5<@bg',
fg: '#5f5f5f',
fgTransparentWeak: ':alpha<0.75<@fg',
fgTransparent: ':alpha<0.5<@fg',
fgHighlighted: ':darken<3<@fg',
divider: 'rgba(0, 0, 0, 0.1)',
indicator: '@accent',
@ -77,5 +79,6 @@
X14: ':alpha<0.5<@navBg',
X15: ':alpha<0<@panel',
X16: ':alpha<0.7<@panel',
X17: ':alpha<0.8<@bg',
},
}

View File

@ -1,5 +1,5 @@
<template>
<div class="fdidabkb" :style="`--height:${height};`">
<div class="fdidabkb" :class="{ center }" :style="`--height:${height};`">
<transition :name="$store.state.animation ? 'header' : ''" mode="out-in" appear>
<button class="_button back" v-if="withBack && canBack" @click.stop="back()"><Fa :icon="faChevronLeft"/></button>
</transition>
@ -31,6 +31,11 @@ export default defineComponent({
required: false,
default: true,
},
center: {
type: Boolean,
required: false,
default: true,
},
},
data() {
@ -67,7 +72,9 @@ export default defineComponent({
<style lang="scss" scoped>
.fdidabkb {
&.center {
text-align: center;
}
> .back {
height: var(--height);
@ -111,8 +118,13 @@ export default defineComponent({
right: 0;
}
&.center {
> .titleContainer {
margin: 0 auto;
}
}
> .titleContainer {
overflow: auto;
white-space: nowrap;

View File

@ -0,0 +1,157 @@
<script lang="ts">
import { defineComponent, h, TransitionGroup } from 'vue';
import { faAngleUp, faAngleDown } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
export default defineComponent({
props: {
items: {
type: Array,
required: true,
},
reversed: {
type: Boolean,
required: false,
default: false
}
},
methods: {
focus() {
this.$slots.default[0].elm.focus();
}
},
render() {
const getDateText = (time: string) => {
const date = new Date(time).getDate();
const month = new Date(time).getMonth() + 1;
return this.$t('monthAndDay', {
month: month.toString(),
day: date.toString()
});
}
return h(TransitionGroup, {
class: 'hmjzthxl',
name: this.reversed ? 'list-reversed' : 'list',
tag: 'div',
}, this.items.map((item, i) => {
const el = this.$slots.default({
item: item
})[0];
if (el.key == null && item.id) el.key = item.id;
if (
i != this.items.length - 1 &&
new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate() &&
!item._prId_ &&
!this.items[i + 1]._prId_ &&
!item._featuredId_ &&
!this.items[i + 1]._featuredId_
) {
const separator = h('div', {
class: 'separator',
key: item.id + ':separator',
}, h('p', {
class: 'date'
}, [
h('span', [
h(FontAwesomeIcon, {
class: 'icon',
icon: faAngleUp,
}),
getDateText(item.createdAt)
]),
h('span', [
getDateText(this.items[i + 1].createdAt),
h(FontAwesomeIcon, {
class: 'icon',
icon: faAngleDown,
})
])
]));
return [el, separator];
} else {
return el;
}
}));
},
});
</script>
<style lang="scss">
.hmjzthxl {
> .list-move {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
> .list-enter-active {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
> .list-enter-from {
opacity: 0;
transform: translateY(-64px);
}
> .list-reversed-enter-active, > .list-reversed-leave-active {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
}
> .list-reversed-enter-from {
opacity: 0;
transform: translateY(64px);
}
}
</style>
<style lang="scss">
.hmjzthxl {
> .separator {
text-align: center;
position: relative;
&:before {
content: "";
display: block;
position: absolute;
top: 50%;
left: 0;
right: 0;
margin: auto;
width: calc(100% - 32px);
height: 1px;
background: var(--divider);
}
> .date {
display: inline-block;
position: relative;
margin: 0;
padding: 0 16px;
line-height: 32px;
text-align: center;
font-size: 12px;
color: var(--dateLabelFg);
background: var(--panel);
> span {
&:first-child {
margin-right: 8px;
> .icon {
margin-right: 8px;
}
}
&:last-child {
margin-left: 8px;
> .icon {
margin-left: 8px;
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,44 @@
<template>
<div class="_monospace">
<span>
<span v-text="hh"></span>
<span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
<span v-text="mm"></span>
<span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span>
<span v-text="ss"></span>
</span>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import * as os from '@/os';
export default defineComponent({
data() {
return {
clock: null,
hh: null,
mm: null,
ss: null,
showColon: true,
};
},
created() {
this.tick();
this.clock = setInterval(this.tick, 1000);
},
beforeUnmount() {
clearInterval(this.clock);
},
methods: {
tick() {
const now = new Date();
this.hh = now.getHours().toString().padStart(2, '0');
this.mm = now.getMinutes().toString().padStart(2, '0');
this.ss = now.getSeconds().toString().padStart(2, '0');
this.showColon = now.getSeconds() % 2 === 0;
}
}
});
</script>

View File

@ -0,0 +1,607 @@
<template>
<div class="mk-app" @contextmenu.self.prevent="onContextmenu">
<XSidebar ref="menu" class="menu" :default-hidden="true"/>
<div class="nav">
<header class="header">
<div class="left">
<button class="_button account" @click="openAccountMenu">
<MkAvatar :user="$i" class="avatar"/><!--<MkAcct class="text" :user="$i"/>-->
</button>
</div>
<div class="right">
<MkA class="item" to="/my/messaging" v-tooltip="$ts.messaging"><Fa class="icon" :icon="faComments"/><i v-if="$i.hasUnreadMessagingMessage"><Fa :icon="faCircle"/></i></MkA>
<MkA class="item" to="/my/messages" v-tooltip="$ts.directNotes"><Fa class="icon" :icon="faEnvelope"/><i v-if="$i.hasUnreadSpecifiedNotes"><Fa :icon="faCircle"/></i></MkA>
<MkA class="item" to="/my/mentions" v-tooltip="$ts.mentions"><Fa class="icon" :icon="faAt"/><i v-if="$i.hasUnreadMentions"><Fa :icon="faCircle"/></i></MkA>
<MkA class="item" to="/my/notifications" v-tooltip="$ts.notifications"><Fa class="icon" :icon="faBell"/><i v-if="$i.hasUnreadNotification"><Fa :icon="faCircle"/></i></MkA>
</div>
</header>
<div class="body">
<div class="container">
<div class="header">{{ $ts.timeline }}</div>
<div class="body">
<MkA to="/timeline/home" class="item" :class="{ active: tl === 'home' }"><Fa :icon="faHome" class="icon"/>{{ $ts._timelines.home }}</MkA>
<MkA to="/timeline/local" class="item" :class="{ active: tl === 'local' }"><Fa :icon="faComments" class="icon"/>{{ $ts._timelines.local }}</MkA>
<MkA to="/timeline/social" class="item" :class="{ active: tl === 'social' }"><Fa :icon="faShareAlt" class="icon"/>{{ $ts._timelines.social }}</MkA>
<MkA to="/timeline/global" class="item" :class="{ active: tl === 'global' }"><Fa :icon="faGlobe" class="icon"/>{{ $ts._timelines.global }}</MkA>
</div>
</div>
<div class="container" v-if="followedChannels">
<div class="header">{{ $ts.channel }} ({{ $ts.following }})<button class="_button add" @click="addChannel"><Fa :icon="faPlus"/></button></div>
<div class="body">
<MkA v-for="channel in followedChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }`, read: !channel.hasUnreadNote }"><Fa :icon="faSatelliteDish" class="icon"/>{{ channel.name }}</MkA>
</div>
</div>
<div class="container" v-if="featuredChannels">
<div class="header">{{ $ts.channel }}<button class="_button add" @click="addChannel"><Fa :icon="faPlus"/></button></div>
<div class="body">
<MkA v-for="channel in featuredChannels" :key="channel.id" :to="`/channels/${ channel.id }`" class="item" :class="{ active: tl === `channel:${ channel.id }` }"><Fa :icon="faSatelliteDish" class="icon"/>{{ channel.name }}</MkA>
</div>
</div>
<div class="container" v-if="lists">
<div class="header">{{ $ts.lists }}<button class="_button add" @click="addList"><Fa :icon="faPlus"/></button></div>
<div class="body">
<MkA v-for="list in lists" :key="list.id" :to="`/my/list/${ list.id }`" class="item" :class="{ active: tl === `list:${ list.id }` }"><Fa :icon="faListUl" class="icon"/>{{ list.name }}</MkA>
</div>
</div>
<div class="container" v-if="antennas">
<div class="header">{{ $ts.antennas }}<button class="_button add" @click="addAntenna"><Fa :icon="faPlus"/></button></div>
<div class="body">
<MkA v-for="antenna in antennas" :key="antenna.id" :to="`/my/antenna/${ antenna.id }`" class="item" :class="{ active: tl === `antenna:${ antenna.id }` }"><Fa :icon="faSatellite" class="icon"/>{{ antenna.name }}</MkA>
</div>
</div>
<div class="container">
<div class="body">
<MkA to="/my/favorites" class="item"><Fa :icon="faStar" class="icon"/>{{ $ts.favorites }}</MkA>
</div>
</div>
</div>
<footer class="footer">
<div class="left">
<button class="_button menu" @click="showMenu">
<Fa class="icon" :icon="faBars"/>
</button>
</div>
<div class="right">
<button class="_button item search" @click="search" v-tooltip="$ts.search">
<Fa :icon="faSearch"/>
</button>
<MkA class="item" to="/settings" v-tooltip="$ts.settings"><Fa class="icon" :icon="faCog"/></MkA>
</div>
</footer>
</div>
<main class="main" @contextmenu.stop="onContextmenu">
<header class="header" ref="header" @click="onHeaderClick">
<div class="left">
<template v-if="tl === 'home'">
<Fa :icon="faHome" class="icon"/>
<div class="title">{{ $ts._timelines.home }}</div>
</template>
<template v-else-if="tl === 'local'">
<Fa :icon="faComments" class="icon"/>
<div class="title">{{ $ts._timelines.local }}</div>
</template>
<template v-else-if="tl === 'social'">
<Fa :icon="faShareAlt" class="icon"/>
<div class="title">{{ $ts._timelines.social }}</div>
</template>
<template v-else-if="tl === 'global'">
<Fa :icon="faGlobe" class="icon"/>
<div class="title">{{ $ts._timelines.global }}</div>
</template>
<template v-else-if="tl.startsWith('channel:')">
<Fa :icon="faSatelliteDish" class="icon"/>
<div class="title" v-if="currentChannel">{{ currentChannel.name }}<div class="description">{{ currentChannel.description }}</div></div>
</template>
</div>
<div class="right">
<div class="instance">{{ instanceName }}</div>
<XHeaderClock class="clock"/>
<button class="_button button search" v-if="tl.startsWith('channel:') && currentChannel" @click="inChannelSearch" v-tooltip="$ts.inChannelSearch">
<Fa :icon="faSearch"/>
</button>
<button class="_button button search" v-else @click="search" v-tooltip="$ts.search">
<Fa :icon="faSearch"/>
</button>
<button class="_button button follow" v-if="tl.startsWith('channel:') && currentChannel" :class="{ followed: currentChannel.isFollowing }" @click="toggleChannelFollow" v-tooltip="currentChannel.isFollowing ? $ts.unfollow : $ts.follow">
<Fa v-if="currentChannel.isFollowing" :icon="faStar"/>
<Fa v-else :icon="farStar"/>
</button>
<button class="_button button menu" v-if="tl.startsWith('channel:') && currentChannel" @click="openChannelMenu">
<Fa :icon="faEllipsisH"/>
</button>
</div>
</header>
<div class="body">
<XTimeline v-if="tl.startsWith('channel:')" src="channel" :key="tl" :channel="tl.replace('channel:', '')"/>
<XTimeline v-else :src="tl" :key="tl"/>
</div>
<footer class="footer">
<XPostForm v-if="tl.startsWith('channel:')" :key="tl" :channel="tl.replace('channel:', '')"/>
<XPostForm v-else/>
</footer>
</main>
<XSide class="side" ref="side" @open="sideViewOpening = true" @close="sideViewOpening = false"/>
<div class="side widgets" :class="{ sideViewOpening }">
<XWidgets/>
</div>
<XCommon/>
</div>
</template>
<script lang="ts">
import { defineComponent, defineAsyncComponent } from 'vue';
import { faLayerGroup, faBars, faHome, faCircle, faWindowMaximize, faColumns, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog, faSearch, faPlus, faStar, faAt, faLink, faEllipsisH, faGlobe } from '@fortawesome/free-solid-svg-icons';
import { faBell, faStar as farStar, faEnvelope, faComments } from '@fortawesome/free-regular-svg-icons';
import { instanceName, url } from '@/config';
import XSidebar from '@/components/sidebar.vue';
import XWidgets from './widgets.vue';
import XCommon from '../_common_/common.vue';
import XSide from './side.vue';
import XTimeline from './timeline.vue';
import XPostForm from './post-form.vue';
import XHeaderClock from './header-clock.vue';
import * as os from '@/os';
import { router } from '@/router';
import { sidebarDef } from '@/sidebar';
import { search } from '@/scripts/search';
import copyToClipboard from '@/scripts/copy-to-clipboard';
export default defineComponent({
components: {
XCommon,
XSidebar,
XWidgets,
XSide, // NOTE: dynamic importAsyncComponentWrapperref
XTimeline,
XPostForm,
XHeaderClock,
},
provide() {
return {
navHook: (path) => {
switch (path) {
case '/timeline/home': this.tl = 'home'; return;
case '/timeline/local': this.tl = 'local'; return;
case '/timeline/social': this.tl = 'social'; return;
case '/timeline/global': this.tl = 'global'; return;
default:
if (path.startsWith('/channels/')) {
this.tl = `channel:${ path.replace('/channels/', '') }`;
return;
}
//os.pageWindow(path);
this.$refs.side.navigate(path);
break;
}
},
sideViewHook: (path) => {
this.$refs.side.navigate(path);
}
};
},
data() {
return {
tl: 'home',
lists: null,
antennas: null,
followedChannels: null,
featuredChannels: null,
currentChannel: null,
menuDef: sidebarDef,
sideViewOpening: false,
instanceName,
faLayerGroup, faBars, faBell, faHome, faCircle, faPencilAlt, faShareAlt, faSatelliteDish, faListUl, faSatellite, faCog, faSearch, faPlus, faStar, farStar, faAt, faLink, faEllipsisH, faGlobe, faComments, faEnvelope,
};
},
created() {
if (window.innerWidth < 1024) {
localStorage.setItem('ui', 'default');
location.reload();
}
router.beforeEach((to, from) => {
this.$refs.side.navigate(to.fullPath);
// search?q=foo return false
//return false;
});
os.api('users/lists/list').then(lists => {
this.lists = lists;
});
os.api('antennas/list').then(antennas => {
this.antennas = antennas;
});
os.api('channels/followed').then(channels => {
this.followedChannels = channels;
});
os.api('channels/featured').then(channels => {
this.featuredChannels = channels;
});
this.$watch('tl', () => {
if (this.tl.startsWith('channel:')) {
os.api('channels/show', { channelId: this.tl.replace('channel:', '') }).then(channel => {
this.currentChannel = channel;
});
}
}, { immediate: true });
},
methods: {
showMenu() {
this.$refs.menu.show();
},
post() {
os.post();
},
search() {
search();
},
async inChannelSearch() {
const { canceled, result: query } = await os.dialog({
title: this.$ts.inChannelSearch,
input: true
});
if (canceled || query == null || query === '') return;
router.push(`/search?q=${encodeURIComponent(query)}&channel=${this.currentChannel.id}`);
},
top() {
window.scroll({ top: 0, behavior: 'smooth' });
},
async toggleChannelFollow() {
if (this.currentChannel.isFollowing) {
await os.apiWithDialog('channels/unfollow', {
channelId: this.currentChannel.id
});
this.currentChannel.isFollowing = false;
} else {
await os.apiWithDialog('channels/follow', {
channelId: this.currentChannel.id
});
this.currentChannel.isFollowing = true;
}
},
openChannelMenu(ev) {
os.modalMenu([{
text: this.$ts.copyUrl,
icon: faLink,
action: () => {
copyToClipboard(`${url}/channels/${this.currentChannel.id}`);
}
}], ev.currentTarget || ev.target);
},
onTransition() {
if (window._scroll) window._scroll();
},
onHeaderClick() {
window.scroll({ top: 0, behavior: 'smooth' });
},
onContextmenu(e) {
const isLink = (el: HTMLElement) => {
if (el.tagName === 'A') return true;
if (el.parentElement) {
return isLink(el.parentElement);
}
};
if (isLink(e.target)) return;
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName) || e.target.attributes['contenteditable']) return;
if (window.getSelection().toString() !== '') return;
const path = this.$route.path;
os.contextMenu([{
type: 'label',
text: path,
}, {
icon: faColumns,
text: this.$ts.openInSideView,
action: () => {
this.$refs.side.navigate(path);
}
}, {
icon: faWindowMaximize,
text: this.$ts.openInWindow,
action: () => {
os.pageWindow(path);
}
}], e);
},
}
});
</script>
<style lang="scss" scoped>
.mk-app {
$header-height: 54px; // TODO:
$ui-font-size: 1em; // TODO:
// 100vh ... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
height: calc(var(--vh, 1vh) * 100);
display: flex;
> .nav {
display: flex;
flex-direction: column;
width: 250px;
height: 100vh;
border-right: solid 1px var(--divider);
> .header, > .footer {
$padding: 8px;
display: flex;
align-items: center;
z-index: 1000;
height: $header-height;
padding: $padding;
box-sizing: border-box;
user-select: none;
&.header {
border-bottom: solid 1px var(--divider);
}
&.footer {
border-top: solid 1px var(--divider);
}
> .left, > .right {
> .item, > .menu {
display: inline-block;
vertical-align: middle;
height: ($header-height - ($padding * 2));
width: ($header-height - ($padding * 2));
box-sizing: border-box;
//opacity: 0.6;
position: relative;
border-radius: 5px;
&:hover {
background: rgba(0, 0, 0, 0.05);
}
> .icon {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: auto;
}
> i {
position: absolute;
top: 8px;
right: 8px;
color: var(--indicator);
font-size: 8px;
line-height: 8px;
animation: blink 1s infinite;
}
}
}
> .left {
flex: 1;
min-width: 0;
> .account {
display: flex;
align-items: center;
padding: 0 8px;
> .avatar {
width: 26px;
height: 26px;
margin-right: 8px;
}
> .text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.9em;
}
}
}
> .right {
margin-left: auto;
}
}
> .body {
flex: 1;
min-width: 0;
overflow: auto;
> .container {
margin-top: 8px;
margin-bottom: 8px;
& + .container {
margin-top: 16px;
}
> .header {
display: flex;
font-size: 0.9em;
padding: 8px 16px;
position: sticky;
top: 0;
background: var(--X17);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
z-index: 1;
color: var(--fgTransparentWeak);
> .add {
margin-left: auto;
color: var(--fgTransparentWeak);
&:hover {
color: var(--fg);
}
}
}
> .body {
padding: 0 8px;
> .item {
display: block;
padding: 6px 8px;
border-radius: 4px;
&:hover {
text-decoration: none;
background: rgba(0, 0, 0, 0.05);
}
&.active, &.active:hover {
background: var(--accent);
color: #fff !important;
}
&.read {
color: var(--fgTransparent);
}
> .icon {
margin-right: 8px;
opacity: 0.6;
}
}
}
}
}
}
> .main {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
height: 100vh;
position: relative;
background: var(--panel);
> .header {
$padding: 8px;
display: flex;
z-index: 1000;
height: $header-height;
padding: $padding;
box-sizing: border-box;
background-color: var(--panel);
border-bottom: solid 1px var(--divider);
user-select: none;
> .left {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
> .icon {
height: ($header-height - ($padding * 2));
width: ($header-height - ($padding * 2));
padding: 10px;
box-sizing: border-box;
margin-right: 4px;
opacity: 0.6;
}
> .title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
font-weight: bold;
> .description {
opacity: 0.6;
font-size: 0.8em;
font-weight: normal;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
> .right {
display: flex;
align-items: center;
min-width: 0;
margin-left: auto;
padding-left: 8px;
> .instance {
margin-right: 16px;
font-size: 0.9em;
}
> .clock {
margin-right: 16px;
}
> .button {
height: ($header-height - ($padding * 2));
width: ($header-height - ($padding * 2));
box-sizing: border-box;
position: relative;
border-radius: 5px;
&:hover {
background: rgba(0, 0, 0, 0.05);
}
&.follow.followed {
color: var(--accent);
}
}
}
}
> .footer {
padding: 0 16px 16px 16px;
}
> .body {
flex: 1;
min-width: 0;
overflow: auto;
}
}
> .side {
width: 350px;
border-left: solid 1px var(--divider);
&.widgets.sideViewOpening {
@media (max-width: 1400px) {
display: none;
}
}
}
}
</style>

View File

@ -0,0 +1,115 @@
<template>
<header class="dehvdgxo">
<MkA class="name" :to="userPage(note.user)" v-user-preview="note.user.id">
<MkUserName :user="note.user"/>
</MkA>
<span class="is-bot" v-if="note.user.isBot">bot</span>
<span class="username"><MkAcct :user="note.user"/></span>
<span class="admin" v-if="note.user.isAdmin"><Fa :icon="faBookmark"/></span>
<span class="moderator" v-if="!note.user.isAdmin && note.user.isModerator"><Fa :icon="farBookmark"/></span>
<div class="info">
<span class="mobile" v-if="note.viaMobile"><Fa :icon="faMobileAlt"/></span>
<MkA class="created-at" :to="notePage(note)">
<MkTime :time="note.createdAt"/>
</MkA>
<span class="visibility" v-if="note.visibility !== 'public'">
<Fa v-if="note.visibility === 'home'" :icon="faHome"/>
<Fa v-if="note.visibility === 'followers'" :icon="faUnlock"/>
<Fa v-if="note.visibility === 'specified'" :icon="faEnvelope"/>
</span>
<span class="localOnly" v-if="note.localOnly"><Fa :icon="faBiohazard"/></span>
</div>
</header>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { faHome, faUnlock, faEnvelope, faMobileAlt, faBookmark, faBiohazard } from '@fortawesome/free-solid-svg-icons';
import { faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons';
import notePage from '@/filters/note';
import { userPage } from '@/filters/user';
import * as os from '@/os';
export default defineComponent({
props: {
note: {
type: Object,
required: true
},
},
data() {
return {
faHome, faUnlock, faEnvelope, faMobileAlt, faBookmark, farBookmark, faBiohazard
};
},
methods: {
notePage,
userPage
}
});
</script>
<style lang="scss" scoped>
.dehvdgxo {
display: flex;
align-items: baseline;
white-space: nowrap;
font-size: 0.9em;
> .name {
display: block;
margin: 0 .5em 0 0;
padding: 0;
overflow: hidden;
font-size: 1em;
font-weight: bold;
text-decoration: none;
text-overflow: ellipsis;
&:hover {
text-decoration: underline;
}
}
> .is-bot {
flex-shrink: 0;
align-self: center;
margin: 0 .5em 0 0;
padding: 1px 6px;
font-size: 80%;
border: solid 1px var(--divider);
border-radius: 3px;
}
> .admin,
> .moderator {
margin-right: 0.5em;
color: var(--badge);
}
> .username {
margin: 0 .5em 0 0;
overflow: hidden;
text-overflow: ellipsis;
}
> .info {
font-size: 0.9em;
opacity: 0.7;
> .mobile {
margin-right: 8px;
}
> .visibility {
margin-left: 8px;
}
> .localOnly {
margin-left: 8px;
}
}
}
</style>

View File

@ -0,0 +1,112 @@
<template>
<div class="hduudsxk">
<MkAvatar class="avatar" :user="note.user"/>
<div class="main">
<XNoteHeader class="header" :note="note" :mini="true"/>
<div class="body">
<p v-if="note.cw != null" class="cw">
<span class="text" v-if="note.cw != ''">{{ note.cw }}</span>
<XCwButton v-model:value="showContent" :note="note"/>
</p>
<div class="content" v-show="note.cw == null || showContent">
<XSubNote-content class="text" :note="note"/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import XNoteHeader from './note-header.vue';
import XSubNoteContent from './sub-note-content.vue';
import XCwButton from '@/components/cw-button.vue';
import * as os from '@/os';
export default defineComponent({
components: {
XNoteHeader,
XSubNoteContent,
XCwButton,
},
props: {
note: {
type: Object,
required: true
}
},
data() {
return {
showContent: false
};
}
});
</script>
<style lang="scss" scoped>
.hduudsxk {
display: flex;
margin: 0;
padding: 0;
overflow: hidden;
font-size: 0.95em;
> .avatar {
@media (min-width: 350px) {
margin: 0 10px 0 0;
width: 44px;
height: 44px;
}
@media (min-width: 500px) {
margin: 0 12px 0 0;
width: 48px;
height: 48px;
}
}
> .avatar {
flex-shrink: 0;
display: block;
margin: 0 10px 0 0;
width: 40px;
height: 40px;
border-radius: 8px;
}
> .main {
flex: 1;
min-width: 0;
> .header {
margin-bottom: 2px;
}
> .body {
> .cw {
cursor: default;
display: block;
margin: 0;
padding: 0;
overflow-wrap: break-word;
> .text {
margin-right: 8px;
}
}
> .content {
> .text {
cursor: default;
margin: 0;
padding: 0;
}
}
}
}
}
</style>

View File

@ -0,0 +1,137 @@
<template>
<div class="wrpstxzv" :class="{ children }">
<div class="main">
<MkAvatar class="avatar" :user="note.user"/>
<div class="body">
<XNoteHeader 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:value="showContent" :note="note"/>
</p>
<div class="content" v-show="note.cw == null || showContent">
<XSubNote-content class="text" :note="note"/>
</div>
</div>
</div>
</div>
<XSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :detail="true" :children="true"/>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import XNoteHeader from './note-header.vue';
import XSubNoteContent from './sub-note-content.vue';
import XCwButton from '@/components/cw-button.vue';
import * as os from '@/os';
export default defineComponent({
name: 'XSub',
components: {
XNoteHeader,
XSubNoteContent,
XCwButton,
},
props: {
note: {
type: Object,
required: true
},
detail: {
type: Boolean,
required: false,
default: false
},
children: {
type: Boolean,
required: false,
default: false
},
// TODO
truncate: {
type: Boolean,
default: true
}
},
data() {
return {
showContent: false,
replies: [],
};
},
created() {
if (this.detail) {
os.api('notes/children', {
noteId: this.note.id,
limit: 5
}).then(replies => {
this.replies = replies;
});
}
},
});
</script>
<style lang="scss" scoped>
.wrpstxzv {
padding: 16px 16px;
font-size: 0.8em;
&.children {
padding: 10px 0 0 16px;
font-size: 1em;
}
> .main {
display: flex;
> .avatar {
flex-shrink: 0;
display: block;
margin: 0 8px 0 0;
width: 36px;
height: 36px;
}
> .body {
flex: 1;
min-width: 0;
> .header {
margin-bottom: 2px;
}
> .body {
> .cw {
cursor: default;
display: block;
margin: 0;
padding: 0;
overflow-wrap: break-word;
> .text {
margin-right: 8px;
}
}
> .content {
> .text {
margin: 0;
padding: 0;
}
}
}
}
}
> .reply {
border-left: solid 1px var(--divider);
margin-top: 10px;
}
}
</style>

1155
src/client/ui/chat/note.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,91 @@
<template>
<div class="" :ref="mounted">
<div class="_fullinfo" v-if="empty">
<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
<div>{{ $ts.noNotes }}</div>
</div>
<MkError v-if="error" @retry="init()"/>
<div v-show="more && reversed" style="margin-bottom: var(--margin);">
<button class="_buttonPrimary" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template>
</button>
</div>
<XList ref="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed">
<XNote :note="note" @update:note="updated(note, $event)" :key="note._featuredId_ || note._prId_ || note.id"/>
</XList>
<div v-show="more && !reversed" style="margin-top: var(--margin);">
<button class="_buttonPrimary" v-appear="$store.state.enableInfiniteScroll ? fetchMore : null" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }">
<template v-if="!moreFetching">{{ $ts.loadMore }}</template>
<template v-if="moreFetching"><MkLoading inline/></template>
</button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import paging from '@/scripts/paging';
import XNote from './note.vue';
import XList from './date-separated-list.vue';
export default defineComponent({
components: {
XNote, XList,
},
mixins: [
paging({
before: (self) => {
self.$emit('before');
},
after: (self, e) => {
self.$emit('after', e);
}
}),
],
props: {
pagination: {
required: true
},
prop: {
type: String,
required: false
}
},
emits: ['before', 'after'],
computed: {
notes(): any[] {
return this.prop ? this.items.map(item => item[this.prop]) : this.items;
},
reversed(): boolean {
return this.pagination.reversed;
}
},
methods: {
updated(oldValue, newValue) {
const i = this.notes.findIndex(n => n === oldValue);
if (this.prop) {
this.items[i][this.prop] = newValue;
} else {
this.items[i] = newValue;
}
},
focus() {
this.$refs.notes.focus();
}
}
});
</script>

View File

@ -0,0 +1,772 @@
<template>
<div class="pxiwixjf"
@dragover.stop="onDragover"
@dragenter="onDragenter"
@dragleave="onDragleave"
@drop.stop="onDrop"
>
<div class="form">
<div class="with-quote" v-if="quoteId"><Fa icon="quote-left"/> {{ $ts.quoteAttached }}<button @click="quoteId = null"><Fa icon="times"/></button></div>
<div v-if="visibility === 'specified'" class="to-specified">
<span style="margin-right: 8px;">{{ $ts.recipient }}</span>
<div class="visibleUsers">
<span v-for="u in visibleUsers" :key="u.id">
<MkAcct :user="u"/>
<button class="_button" @click="removeVisibleUser(u)"><Fa :icon="faTimes"/></button>
</span>
<button @click="addVisibleUser" class="_buttonPrimary"><Fa :icon="faPlus" fixed-width/></button>
</div>
</div>
<input v-show="useCw" ref="cw" class="cw" v-model="cw" :placeholder="$ts.annotation" @keydown="onKeydown">
<textarea v-model="text" class="text" :class="{ withCw: useCw }" ref="text" :disabled="posting" :placeholder="placeholder" @keydown="onKeydown" @paste="onPaste" @compositionupdate="onCompositionUpdate" @compositionend="onCompositionEnd" />
<XPostFormAttaches class="attaches" :files="files" @updated="updateFiles" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
<XPollEditor v-if="poll" :poll="poll" @destroyed="poll = null" @updated="onPollUpdate"/>
<footer>
<div class="left">
<button class="_button" @click="chooseFileFrom" v-tooltip="$ts.attachFile"><Fa :icon="faPhotoVideo"/></button>
<button class="_button" @click="togglePoll" :class="{ active: poll }" v-tooltip="$ts.poll"><Fa :icon="faPollH"/></button>
<button class="_button" @click="useCw = !useCw" :class="{ active: useCw }" v-tooltip="$ts.useCw"><Fa :icon="faEyeSlash"/></button>
<button class="_button" @click="insertMention" v-tooltip="$ts.mention"><Fa :icon="faAt"/></button>
<button class="_button" @click="insertEmoji" v-tooltip="$ts.emoji"><Fa :icon="faLaughSquint"/></button>
<button class="_button" @click="showActions" v-tooltip="$ts.plugin" v-if="postFormActions.length > 0"><Fa :icon="faPlug"/></button>
</div>
<div class="right">
<span class="text-count" :class="{ over: textLength > max }">{{ max - textLength }}</span>
<span class="local-only" v-if="localOnly"><Fa :icon="faBiohazard"/></span>
<button class="_button visibility" @click="setVisibility" ref="visibilityButton" v-tooltip="$ts.visibility" :disabled="channel != null">
<span v-if="visibility === 'public'"><Fa :icon="faGlobe"/></span>
<span v-if="visibility === 'home'"><Fa :icon="faHome"/></span>
<span v-if="visibility === 'followers'"><Fa :icon="faUnlock"/></span>
<span v-if="visibility === 'specified'"><Fa :icon="faEnvelope"/></span>
</button>
<button class="submit _buttonPrimary" :disabled="!canPost" @click="post">{{ submitText }}<Fa :icon="reply ? faReply : renote ? faQuoteRight : faPaperPlane"/></button>
</div>
</footer>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, defineAsyncComponent } from 'vue';
import { faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug } from '@fortawesome/free-solid-svg-icons';
import { faEyeSlash, faLaughSquint } from '@fortawesome/free-regular-svg-icons';
import insertTextAtCursor from 'insert-text-at-cursor';
import { length } from 'stringz';
import { toASCII } from 'punycode';
import { parse } from '../../../mfm/parse';
import { host, url } from '@/config';
import { erase, unique } from '../../../prelude/array';
import extractMentions from '../../../misc/extract-mentions';
import getAcct from '../../../misc/acct/render';
import { formatTimeString } from '../../../misc/format-time-string';
import { Autocomplete } from '@/scripts/autocomplete';
import { noteVisibilities } from '../../../types';
import * as os from '@/os';
import { selectFile } from '@/scripts/select-file';
import { notePostInterruptors, postFormActions } from '@/store';
import { isMobile } from '@/scripts/is-mobile';
export default defineComponent({
components: {
XPostFormAttaches: defineAsyncComponent(() => import('@/components/post-form-attaches.vue')),
XPollEditor: defineAsyncComponent(() => import('@/components/poll-editor.vue'))
},
props: {
reply: {
type: Object,
required: false
},
renote: {
type: Object,
required: false
},
channel: {
type: String,
required: false
},
mention: {
type: Object,
required: false
},
specified: {
type: Object,
required: false
},
initialText: {
type: String,
required: false
},
initialNote: {
type: Object,
required: false
},
instant: {
type: Boolean,
required: false,
default: false
},
autofocus: {
type: Boolean,
required: false,
default: false
},
},
emits: ['posted', 'cancel', 'esc'],
data() {
return {
posting: false,
text: '',
files: [],
poll: null,
useCw: false,
cw: null,
localOnly: this.$store.state.rememberNoteVisibility ? this.$store.state.localOnly : this.$store.state.defaultNoteLocalOnly,
visibility: this.$store.state.rememberNoteVisibility ? this.$store.state.visibility : this.$store.state.defaultNoteVisibility,
visibleUsers: [],
autocomplete: null,
draghover: false,
quoteId: null,
recentHashtags: JSON.parse(localStorage.getItem('hashtags') || '[]'),
imeText: '',
postFormActions,
faReply, faQuoteRight, faPaperPlane, faTimes, faUpload, faPollH, faGlobe, faHome, faUnlock, faEnvelope, faEyeSlash, faLaughSquint, faPlus, faPhotoVideo, faAt, faBiohazard, faPlug
};
},
computed: {
draftKey(): string {
let key = this.channel ? `channel:${this.channel}` : '';
if (this.renote) {
key += `renote:${this.renote.id}`;
} else if (this.reply) {
key += `reply:${this.reply.id}`;
} else {
key += 'note';
}
return key;
},
placeholder(): string {
if (this.renote) {
return this.$ts._postForm.quotePlaceholder;
} else if (this.reply) {
return this.$ts._postForm.replyPlaceholder;
} else if (this.channel) {
return this.$ts._postForm.channelPlaceholder;
} else {
const xs = [
this.$ts._postForm._placeholders.a,
this.$ts._postForm._placeholders.b,
this.$ts._postForm._placeholders.c,
this.$ts._postForm._placeholders.d,
this.$ts._postForm._placeholders.e,
this.$ts._postForm._placeholders.f
];
return xs[Math.floor(Math.random() * xs.length)];
}
},
submitText(): string {
return this.renote
? this.$ts.quote
: this.reply
? this.$ts.reply
: this.$ts.note;
},
textLength(): number {
return length((this.text + this.imeText).trim());
},
canPost(): boolean {
return !this.posting &&
(1 <= this.textLength || 1 <= this.files.length || !!this.poll || !!this.renote) &&
(this.textLength <= this.max) &&
(!this.poll || this.poll.choices.length >= 2);
},
max(): number {
return this.$instance ? this.$instance.maxNoteTextLength : 1000;
}
},
mounted() {
if (this.initialText) {
this.text = this.initialText;
}
if (this.mention) {
this.text = this.mention.host ? `@${this.mention.username}@${toASCII(this.mention.host)}` : `@${this.mention.username}`;
this.text += ' ';
}
if (this.reply && this.reply.user.host != null) {
this.text = `@${this.reply.user.username}@${toASCII(this.reply.user.host)} `;
}
if (this.reply && this.reply.text != null) {
const ast = parse(this.reply.text);
for (const x of extractMentions(ast)) {
const mention = x.host ? `@${x.username}@${toASCII(x.host)}` : `@${x.username}`;
//
if (this.$i.username == x.username && x.host == null) continue;
if (this.$i.username == x.username && x.host == host) continue;
//
if (this.text.indexOf(`${mention} `) != -1) continue;
this.text += `${mention} `;
}
}
if (this.channel) {
this.visibility = 'public';
this.localOnly = true; // TODO:
}
//
if (this.reply && ['home', 'followers', 'specified'].includes(this.reply.visibility)) {
this.visibility = this.reply.visibility;
if (this.reply.visibility === 'specified') {
os.api('users/show', {
userIds: this.reply.visibleUserIds.filter(uid => uid !== this.$i.id && uid !== this.reply.userId)
}).then(users => {
this.visibleUsers.push(...users);
});
if (this.reply.userId !== this.$i.id) {
os.api('users/show', { userId: this.reply.userId }).then(user => {
this.visibleUsers.push(user);
});
}
}
}
if (this.specified) {
this.visibility = 'specified';
this.visibleUsers.push(this.specified);
}
// keep cw when reply
if (this.$store.state.keepCw && this.reply && this.reply.cw) {
this.useCw = true;
this.cw = this.reply.cw;
}
if (this.autofocus) {
this.focus();
this.$nextTick(() => {
this.focus();
});
}
// TODO: detach when unmount
new Autocomplete(this.$refs.text, this, { model: 'text' });
new Autocomplete(this.$refs.cw, this, { model: 'cw' });
this.$nextTick(() => {
// 稿
if (!this.instant && !this.mention && !this.specified) {
const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftKey];
if (draft) {
this.text = draft.data.text;
this.useCw = draft.data.useCw;
this.cw = draft.data.cw;
this.visibility = draft.data.visibility;
this.localOnly = draft.data.localOnly;
this.files = (draft.data.files || []).filter(e => e);
if (draft.data.poll) {
this.poll = draft.data.poll;
}
}
}
//
if (this.initialNote) {
const init = this.initialNote;
this.text = init.text ? init.text : '';
this.files = init.files;
this.cw = init.cw;
this.useCw = init.cw != null;
if (init.poll) {
this.poll = init.poll;
}
this.visibility = init.visibility;
this.localOnly = init.localOnly;
this.quoteId = init.renote ? init.renote.id : null;
}
this.$nextTick(() => this.watch());
});
},
methods: {
watch() {
this.$watch('text', () => this.saveDraft());
this.$watch('useCw', () => this.saveDraft());
this.$watch('cw', () => this.saveDraft());
this.$watch('poll', () => this.saveDraft());
this.$watch('files', () => this.saveDraft(), { deep: true });
this.$watch('visibility', () => this.saveDraft());
this.$watch('localOnly', () => this.saveDraft());
},
togglePoll() {
if (this.poll) {
this.poll = null;
} else {
this.poll = {
choices: ['', ''],
multiple: false,
expiresAt: null,
expiredAfter: null,
};
}
},
addTag(tag: string) {
insertTextAtCursor(this.$refs.text, ` #${tag} `);
},
focus() {
(this.$refs.text as any).focus();
},
chooseFileFrom(ev) {
selectFile(ev.currentTarget || ev.target, this.$ts.attachFile, true).then(files => {
for (const file of files) {
this.files.push(file);
}
});
},
detachFile(id) {
this.files = this.files.filter(x => x.id != id);
},
updateFiles(files) {
this.files = files;
},
updateFileSensitive(file, sensitive) {
this.files[this.files.findIndex(x => x.id === file.id)].isSensitive = sensitive;
},
updateFileName(file, name) {
this.files[this.files.findIndex(x => x.id === file.id)].name = name;
},
upload(file: File, name?: string) {
os.upload(file, this.$store.state.uploadFolder, name).then(res => {
this.files.push(res);
});
},
onPollUpdate(poll) {
this.poll = poll;
this.saveDraft();
},
setVisibility() {
if (this.channel) {
// TODO: information dialog
return;
}
os.popup(import('@/components/visibility-picker.vue'), {
currentVisibility: this.visibility,
currentLocalOnly: this.localOnly,
src: this.$refs.visibilityButton
}, {
changeVisibility: visibility => {
this.visibility = visibility;
if (this.$store.state.rememberNoteVisibility) {
this.$store.set('visibility', visibility);
}
},
changeLocalOnly: localOnly => {
this.localOnly = localOnly;
if (this.$store.state.rememberNoteVisibility) {
this.$store.set('localOnly', localOnly);
}
}
}, 'closed');
},
addVisibleUser() {
os.selectUser().then(user => {
this.visibleUsers.push(user);
});
},
removeVisibleUser(user) {
this.visibleUsers = erase(user, this.visibleUsers);
},
clear() {
this.text = '';
this.files = [];
this.poll = null;
this.quoteId = null;
},
onKeydown(e: KeyboardEvent) {
if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
if (e.which === 27) this.$emit('esc');
},
onCompositionUpdate(e: CompositionEvent) {
this.imeText = e.data;
},
onCompositionEnd(e: CompositionEvent) {
this.imeText = '';
},
async onPaste(e: ClipboardEvent) {
for (const { item, i } of Array.from(e.clipboardData.items).map((item, i) => ({item, i}))) {
if (item.kind == 'file') {
const file = item.getAsFile();
const lio = file.name.lastIndexOf('.');
const ext = lio >= 0 ? file.name.slice(lio) : '';
const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
this.upload(file, formatted);
}
}
const paste = e.clipboardData.getData('text');
if (!this.renote && !this.quoteId && paste.startsWith(url + '/notes/')) {
e.preventDefault();
os.dialog({
type: 'info',
text: this.$ts.quoteQuestion,
showCancelButton: true
}).then(({ canceled }) => {
if (canceled) {
insertTextAtCursor(this.$refs.text, paste);
return;
}
this.quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
});
}
},
onDragover(e) {
if (!e.dataTransfer.items[0]) return;
const isFile = e.dataTransfer.items[0].kind == 'file';
const isDriveFile = e.dataTransfer.types[0] == _DATA_TRANSFER_DRIVE_FILE_;
if (isFile || isDriveFile) {
e.preventDefault();
this.draghover = true;
e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move';
}
},
onDragenter(e) {
this.draghover = true;
},
onDragleave(e) {
this.draghover = false;
},
onDrop(e): void {
this.draghover = false;
//
if (e.dataTransfer.files.length > 0) {
e.preventDefault();
for (const x of Array.from(e.dataTransfer.files)) this.upload(x);
return;
}
//#region
const driveFile = e.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
if (driveFile != null && driveFile != '') {
const file = JSON.parse(driveFile);
this.files.push(file);
e.preventDefault();
}
//#endregion
},
saveDraft() {
if (this.instant) return;
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
data[this.draftKey] = {
updatedAt: new Date(),
data: {
text: this.text,
useCw: this.useCw,
cw: this.cw,
visibility: this.visibility,
localOnly: this.localOnly,
files: this.files,
poll: this.poll
}
};
localStorage.setItem('drafts', JSON.stringify(data));
},
deleteDraft() {
const data = JSON.parse(localStorage.getItem('drafts') || '{}');
delete data[this.draftKey];
localStorage.setItem('drafts', JSON.stringify(data));
},
async post() {
let data = {
text: this.text == '' ? undefined : this.text,
fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
replyId: this.reply ? this.reply.id : undefined,
renoteId: this.renote ? this.renote.id : this.quoteId ? this.quoteId : undefined,
channelId: this.channel ? this.channel : undefined,
poll: this.poll,
cw: this.useCw ? this.cw || '' : undefined,
localOnly: this.localOnly,
visibility: this.visibility,
visibleUserIds: this.visibility == 'specified' ? this.visibleUsers.map(u => u.id) : undefined,
viaMobile: isMobile
};
// plugin
if (notePostInterruptors.length > 0) {
for (const interruptor of notePostInterruptors) {
data = await interruptor.handler(JSON.parse(JSON.stringify(data)));
}
}
this.posting = true;
os.api('notes/create', data).then(() => {
this.clear();
this.$nextTick(() => {
this.deleteDraft();
this.$emit('posted');
if (this.text && this.text != '') {
const hashtags = parse(this.text).filter(x => x.node.type === 'hashtag').map(x => x.node.props.hashtag);
const history = JSON.parse(localStorage.getItem('hashtags') || '[]') as string[];
localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
}
this.posting = false;
});
}).catch(err => {
this.posting = false;
os.dialog({
type: 'error',
text: err.message + '\n' + (err as any).id,
});
});
},
cancel() {
this.$emit('cancel');
},
insertMention() {
os.selectUser().then(user => {
insertTextAtCursor(this.$refs.text, '@' + getAcct(user) + ' ');
});
},
async insertEmoji(ev) {
os.pickEmoji(ev.currentTarget || ev.target).then(emoji => {
insertTextAtCursor(this.$refs.text, emoji);
});
},
showActions(ev) {
os.modalMenu(postFormActions.map(action => ({
text: action.title,
action: () => {
action.handler({
text: this.text
}, (key, value) => {
if (key === 'text') { this.text = value; }
});
}
})), ev.currentTarget || ev.target);
}
}
});
</script>
<style lang="scss" scoped>
.pxiwixjf {
position: relative;
border: solid 1px var(--divider);
border-radius: 8px;
> .form {
> .preview {
padding: 16px;
}
> .with-quote {
margin: 0 0 8px 0;
color: var(--accent);
> button {
padding: 4px 8px;
color: var(--accentAlpha04);
&:hover {
color: var(--accentAlpha06);
}
&:active {
color: var(--accentDarken30);
}
}
}
> .to-specified {
padding: 6px 24px;
margin-bottom: 8px;
overflow: auto;
white-space: nowrap;
> .visibleUsers {
display: inline;
top: -1px;
font-size: 14px;
> button {
padding: 4px;
border-radius: 8px;
}
> span {
margin-right: 14px;
padding: 8px 0 8px 8px;
border-radius: 8px;
background: var(--X4);
> button {
padding: 4px 8px;
}
}
}
}
> .cw,
> .text {
display: block;
box-sizing: border-box;
padding: 16px;
margin: 0;
width: 100%;
font-size: 16px;
border: none;
border-radius: 0;
background: transparent;
color: var(--fg);
font-family: inherit;
&:focus {
outline: none;
}
&:disabled {
opacity: 0.5;
}
}
> .cw {
z-index: 1;
padding-bottom: 8px;
border-bottom: solid 1px var(--divider);
}
> .text {
max-width: 100%;
min-width: 100%;
min-height: 60px;
&.withCw {
padding-top: 8px;
}
}
> footer {
$height: 44px;
display: flex;
padding: 0 8px 8px 8px;
line-height: $height;
> .left {
> button {
display: inline-block;
padding: 0;
margin: 0;
font-size: 16px;
width: $height;
height: $height;
border-radius: 6px;
&:hover {
background: var(--X5);
}
&.active {
color: var(--accent);
}
}
}
> .right {
margin-left: auto;
> .text-count {
opacity: 0.7;
}
> .visibility {
width: $height;
margin: 0 8px;
& + .localOnly {
margin-left: 0 !important;
}
}
> .local-only {
margin: 0 0 0 12px;
opacity: 0.7;
}
> .submit {
margin: 0;
padding: 0 12px;
line-height: 34px;
font-weight: bold;
border-radius: 4px;
&:disabled {
opacity: 0.7;
}
> [data-icon] {
margin-left: 6px;
}
}
}
}
}
}
</style>

159
src/client/ui/chat/side.vue Normal file
View File

@ -0,0 +1,159 @@
<template>
<div class="mrajymqm _narrow_" v-if="component">
<header class="header" @contextmenu.prevent.stop="onContextmenu">
<button class="_button" @click="back()" v-if="history.length > 0"><Fa :icon="faChevronLeft"/></button>
<XHeader class="title" :info="pageInfo" :with-back="false" :center="false"/>
<button class="_button" @click="close()"><Fa :icon="faTimes"/></button>
</header>
<component :is="component" v-bind="props" :ref="changePage"/>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { faTimes, faChevronLeft, faExpandAlt, faWindowMaximize, faExternalLinkAlt, faLink } from '@fortawesome/free-solid-svg-icons';
import XHeader from '../_common_/header.vue';
import * as os from '@/os';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { resolve } from '@/router';
import { url } from '@/config';
export default defineComponent({
components: {
XHeader
},
provide() {
return {
navHook: (path) => {
this.navigate(path);
}
};
},
data() {
return {
path: null,
component: null,
props: {},
pageInfo: null,
history: [],
faTimes, faChevronLeft,
};
},
computed: {
url(): string {
return url + this.path;
}
},
methods: {
changePage(page) {
if (page == null) return;
if (page.INFO) {
this.pageInfo = page.INFO;
}
},
navigate(path, record = true) {
if (record && this.path) this.history.push(this.path);
this.path = path;
const { component, props } = resolve(path);
this.component = component;
this.props = props;
this.$emit('open');
},
back() {
this.navigate(this.history.pop(), false);
},
close() {
this.path = null;
this.component = null;
this.props = {};
this.$emit('close');
},
onContextmenu(e) {
os.contextMenu([{
type: 'label',
text: this.path,
}, {
icon: faExpandAlt,
text: this.$ts.showInPage,
action: () => {
this.$router.push(this.path);
this.close();
}
}, {
icon: faWindowMaximize,
text: this.$ts.openInWindow,
action: () => {
os.pageWindow(this.path);
this.close();
}
}, null, {
icon: faExternalLinkAlt,
text: this.$ts.openInNewTab,
action: () => {
window.open(this.url, '_blank');
this.close();
}
}, {
icon: faLink,
text: this.$ts.copyLink,
action: () => {
copyToClipboard(this.url);
}
}], e);
}
}
});
</script>
<style lang="scss" scoped>
.mrajymqm {
$header-height: 54px; // TODO:
--section-padding: 16px;
--margin: var(--marginHalf);
height: 100%;
overflow: auto;
box-sizing: border-box;
> .header {
display: flex;
position: sticky;
z-index: 1000;
top: 0;
height: $header-height;
width: 100%;
line-height: $header-height;
font-weight: bold;
//background-color: var(--panel);
-webkit-backdrop-filter: blur(32px);
backdrop-filter: blur(32px);
background-color: var(--header);
border-bottom: solid 1px var(--divider);
box-sizing: border-box;
> ._button {
height: $header-height;
width: $header-height;
&:hover {
color: var(--fgHighlighted);
}
}
> .title {
flex: 1;
position: relative;
}
}
}
</style>

View File

@ -0,0 +1,13 @@
import { markRaw } from 'vue';
import { Storage } from '../../pizzax';
export const store = markRaw(new Storage('chatUi', {
widgets: {
where: 'account',
default: [] as {
name: string;
id: string;
data: Record<string, any>;
}[]
},
}));

View File

@ -0,0 +1,64 @@
<template>
<div class="wrmlmaau">
<div class="body">
<span v-if="note.isHidden" style="opacity: 0.5">({{ $ts.private }})</span>
<span v-if="note.deletedAt" style="opacity: 0.5">({{ $ts.deleted }})</span>
<MkA class="reply" v-if="note.replyId" :to="`/notes/${note.replyId}`"><Fa :icon="faReply"/></MkA>
<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
<MkA class="rp" v-if="note.renoteId" :to="`/notes/${note.renoteId}`">RN: ...</MkA>
</div>
<details v-if="note.files.length > 0">
<summary>({{ $t('withNFiles', { n: note.files.length }) }})</summary>
<XMediaList :media-list="note.files"/>
</details>
<details v-if="note.poll">
<summary>{{ $ts.poll }}</summary>
<XPoll :note="note"/>
</details>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { faReply } from '@fortawesome/free-solid-svg-icons';
import XPoll from '@/components/poll.vue';
import XMediaList from '@/components/media-list.vue';
import * as os from '@/os';
export default defineComponent({
components: {
XPoll,
XMediaList,
},
props: {
note: {
type: Object,
required: true
}
},
data() {
return {
faReply
};
}
});
</script>
<style lang="scss" scoped>
.wrmlmaau {
overflow-wrap: break-word;
> .body {
> .reply {
margin-right: 6px;
color: var(--accent);
}
> .rp {
margin-left: 4px;
font-style: oblique;
color: var(--renote);
}
}
}
</style>

View File

@ -0,0 +1,234 @@
<template>
<div class="dbiokgaf">
<div class="new" v-if="queue > 0" :style="{ width: width + 'px', [pagination.reversed ? 'bottom' : 'top']: pagination.reversed ? bottom + 'px' : top + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div>
<XNotes class="tl" ref="tl" :pagination="pagination" @queue="queueUpdated" v-follow="pagination.reversed"/>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import XNotes from './notes.vue';
import * as os from '@/os';
import * as sound from '@/scripts/sound';
import { scrollToBottom, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
import follow from '@/directives/follow-append';
export default defineComponent({
components: {
XNotes
},
directives: {
follow
},
provide() {
return {
inChannel: this.src === 'channel'
};
},
props: {
src: {
type: String,
required: true
},
list: {
type: String,
required: false
},
antenna: {
type: String,
required: false
},
channel: {
type: String,
required: false
},
sound: {
type: Boolean,
required: false,
default: false,
}
},
emits: ['note', 'queue', 'before', 'after'],
data() {
return {
connection: null,
connection2: null,
pagination: null,
baseQuery: {
includeMyRenotes: this.$store.state.showMyRenotes,
includeRenotedMyNotes: this.$store.state.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.showLocalRenotes
},
query: {},
queue: 0,
width: 0,
top: 0,
bottom: 0,
};
},
created() {
const prepend = note => {
(this.$refs.tl as any).prepend(note);
this.$emit('note');
if (this.sound) {
sound.play(note.userId === this.$i.id ? 'noteMy' : 'note');
}
};
const onUserAdded = () => {
(this.$refs.tl as any).reload();
};
const onUserRemoved = () => {
(this.$refs.tl as any).reload();
};
const onChangeFollowing = () => {
if (!this.$refs.tl.backed) {
this.$refs.tl.reload();
}
};
let endpoint;
let reversed = false;
if (this.src == 'antenna') {
endpoint = 'antennas/notes';
this.query = {
antennaId: this.antenna
};
this.connection = os.stream.connectToChannel('antenna', {
antennaId: this.antenna
});
this.connection.on('note', prepend);
} else if (this.src == 'home') {
endpoint = 'notes/timeline';
this.connection = os.stream.useSharedConnection('homeTimeline');
this.connection.on('note', prepend);
this.connection2 = os.stream.useSharedConnection('main');
this.connection2.on('follow', onChangeFollowing);
this.connection2.on('unfollow', onChangeFollowing);
} else if (this.src == 'local') {
endpoint = 'notes/local-timeline';
this.connection = os.stream.useSharedConnection('localTimeline');
this.connection.on('note', prepend);
} else if (this.src == 'social') {
endpoint = 'notes/hybrid-timeline';
this.connection = os.stream.useSharedConnection('hybridTimeline');
this.connection.on('note', prepend);
} else if (this.src == 'global') {
endpoint = 'notes/global-timeline';
this.connection = os.stream.useSharedConnection('globalTimeline');
this.connection.on('note', prepend);
} else if (this.src == 'mentions') {
endpoint = 'notes/mentions';
this.connection = os.stream.useSharedConnection('main');
this.connection.on('mention', prepend);
} else if (this.src == 'directs') {
endpoint = 'notes/mentions';
this.query = {
visibility: 'specified'
};
const onNote = note => {
if (note.visibility == 'specified') {
prepend(note);
}
};
this.connection = os.stream.useSharedConnection('main');
this.connection.on('mention', onNote);
} else if (this.src == 'list') {
endpoint = 'notes/user-list-timeline';
this.query = {
listId: this.list
};
this.connection = os.stream.connectToChannel('userList', {
listId: this.list
});
this.connection.on('note', prepend);
this.connection.on('userAdded', onUserAdded);
this.connection.on('userRemoved', onUserRemoved);
} else if (this.src == 'channel') {
endpoint = 'channels/timeline';
reversed = true;
this.query = {
channelId: this.channel
};
this.connection = os.stream.connectToChannel('channel', {
channelId: this.channel
});
this.connection.on('note', prepend);
}
this.pagination = {
endpoint: endpoint,
reversed,
limit: 10,
params: init => ({
untilDate: init ? undefined : (this.date ? this.date.getTime() : undefined),
...this.baseQuery, ...this.query
})
};
},
mounted() {
},
beforeUnmount() {
this.connection.dispose();
if (this.connection2) this.connection2.dispose();
},
methods: {
focus() {
this.$refs.tl.focus();
},
goTop() {
const container = getScrollContainer(this.$el);
container.scrollTop = 0;
},
queueUpdated(q) {
if (this.$el.offsetWidth !== 0) {
const rect = this.$el.getBoundingClientRect();
const scrollTop = getScrollPosition(this.$el);
this.width = this.$el.offsetWidth;
this.top = rect.top + scrollTop;
this.bottom = this.$el.offsetHeight;
}
this.queue = q;
},
}
});
</script>
<style lang="scss" scoped>
.dbiokgaf {
padding: 16px 0;
// TODO: position sticky
overflow: hidden;
> .new {
position: fixed;
z-index: 1000;
> button {
display: block;
margin: 16px auto;
padding: 8px 16px;
border-radius: 32px;
}
}
}
</style>

View File

@ -0,0 +1,61 @@
<template>
<div class="qydbhufi">
<XWidgets :edit="edit" :widgets="widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/>
<button v-if="edit" @click="edit = false" class="_textButton" style="font-size: 0.9em;">{{ $ts.editWidgetsExit }}</button>
<button v-else @click="edit = true" class="_textButton" style="font-size: 0.9em;">{{ $ts.editWidgets }}</button>
</div>
</template>
<script lang="ts">
import { defineComponent, defineAsyncComponent } from 'vue';
import XWidgets from '@/components/widgets.vue';
import { store } from './store.ts';
export default defineComponent({
components: {
XWidgets,
},
data() {
return {
edit: false,
widgets: store.reactiveState.widgets
};
},
methods: {
addWidget(widget) {
store.set('widgets', [widget, ...store.state.widgets]);
},
removeWidget(widget) {
store.set('widgets', store.state.widgets.filter(w => w.id != widget.id));
},
updateWidget({ id, data }) {
store.set('widgets', store.state.widgets.map(w => w.id === id ? {
...w,
data: data
} : w));
},
updateWidgets(widgets) {
store.set('widgets', widgets);
}
}
});
</script>
<style lang="scss" scoped>
.qydbhufi {
height: 100%;
box-sizing: border-box;
overflow: auto;
padding: var(--margin);
::v-deep(._panel) {
box-shadow: none;
}
}
</style>

View File

@ -3,49 +3,22 @@
<template #header><Fa :icon="faWindowMaximize" style="margin-right: 8px;"/>{{ column.name }}</template>
<div class="wtdtxvec">
<template v-if="edit">
<header>
<MkSelect v-model:value="widgetAdderSelected" style="margin-bottom: var(--margin)">
<template #label>{{ $ts.selectWidget }}</template>
<option v-for="widget in widgets" :value="widget" :key="widget">{{ $t(`_widgets.${widget}`) }}</option>
</MkSelect>
<MkButton inline @click="addWidget" primary><Fa :icon="faPlus"/> {{ $ts.add }}</MkButton>
<MkButton inline @click="edit = false">{{ $ts.close }}</MkButton>
</header>
<XDraggable
v-model="_widgets"
item-key="id"
animation="150"
>
<template #item="{element}">
<div class="customize-container" @click="widgetFunc(element.id)">
<button class="remove _button" @click.prevent.stop="removeWidget(element)"><Fa :icon="faTimes"/></button>
<component :is="`mkw-${element.name}`" :widget="element" :setting-callback="setting => settings[element.id] = setting" :column="column" @updateProps="saveWidget(element.id, $event)"/>
</div>
</template>
</XDraggable>
</template>
<component v-else class="widget" v-for="widget in column.widgets" :is="`mkw-${widget.name}`" :key="widget.id" :widget="widget" :column="column" @updateProps="saveWidget(widget.id, $event)"/>
<XWidgets :edit="edit" :widgets="column.widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/>
</div>
</XColumn>
</template>
<script lang="ts">
import { defineComponent, defineAsyncComponent } from 'vue';
import { v4 as uuid } from 'uuid';
import { faWindowMaximize, faTimes, faCog, faPlus } from '@fortawesome/free-solid-svg-icons';
import MkSelect from '@/components/ui/select.vue';
import MkButton from '@/components/ui/button.vue';
import XWidgets from '@/components/widgets.vue';
import XColumn from './column.vue';
import { widgets } from '../../widgets';
import { addColumnWidget, removeColumnWidget, setColumnWidgets, updateColumnWidget } from './deck-store';
export default defineComponent({
components: {
XColumn,
XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
MkSelect,
MkButton,
XWidgets,
},
props: {
@ -62,49 +35,27 @@ export default defineComponent({
data() {
return {
edit: false,
widgetAdderSelected: null,
widgets,
settings: {},
faWindowMaximize, faTimes, faPlus
};
},
computed: {
_widgets: {
get() {
return this.column.widgets;
},
set(value) {
setColumnWidgets(this.column.id, value);
}
}
},
methods: {
widgetFunc(id) {
this.settings[id]();
},
addWidget() {
if (this.widgetAdderSelected == null) return;
addColumnWidget(this.column.id, {
name: this.widgetAdderSelected,
id: uuid(),
data: {}
});
this.widgetAdderSelected = null;
addWidget(widget) {
addColumnWidget(this.column.id, widget);
},
removeWidget(widget) {
removeColumnWidget(this.column.id, widget);
},
saveWidget(id, data) {
updateWidget({ id, data }) {
updateColumnWidget(this.column.id, id, data);
},
updateWidgets(widgets) {
setColumnWidgets(this.column.id, widgets);
},
func() {
this.edit = !this.edit;
}
@ -114,46 +65,12 @@ export default defineComponent({
<style lang="scss" scoped>
.wtdtxvec {
._panel {
--margin: 8px;
padding: 0 var(--margin);
::v-deep(._panel) {
box-shadow: none;
}
> header {
padding: 16px;
> * {
width: 100%;
padding: 4px;
}
}
> .widget, .customize-container {
margin: 8px;
&:first-of-type {
margin-top: 0;
}
}
.customize-container {
position: relative;
cursor: move;
> *:not(.remove) {
pointer-events: none;
}
> .remove {
position: absolute;
z-index: 2;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
color: #fff;
background: rgba(#000, 0.7);
border-radius: 4px;
}
}
}
</style>

View File

@ -1,46 +1,21 @@
<template>
<div class="efzpzdvf">
<template v-if="editMode">
<MkButton primary @click="addWidget" class="add"><Fa :icon="faPlus"/></MkButton>
<XDraggable
v-model="widgets"
item-key="id"
handle=".handle"
animation="150"
class="sortable"
>
<template #item="{element}">
<div class="customize-container _panel">
<header>
<span class="handle"><Fa :icon="faBars"/></span>{{ $t('_widgets.' + element.name) }}<button class="remove _button" @click="removeWidget(element)"><Fa :icon="faTimes"/></button>
</header>
<div @click="widgetFunc(element.id)">
<component class="_inContainer_ _forceContainerFull_" :is="`mkw-${element.name}`" :widget="element" :ref="element.id" :setting-callback="setting => settings[element.id] = setting" @updateProps="saveWidget(element.id, $event)"/>
</div>
</div>
</template>
</XDraggable>
<button @click="editMode = false" class="_textButton" style="font-size: 0.9em;"><Fa :icon="faCheck"/> {{ $ts.editWidgetsExit }}</button>
</template>
<template v-else>
<component v-for="widget in widgets" class="_inContainer_ _forceContainerFull_" :is="`mkw-${widget.name}`" :key="widget.id" :widget="widget" @updateProps="saveWidget(widget.id, $event)"/>
<button @click="editMode = true" class="_textButton" style="font-size: 0.9em;"><Fa :icon="faPencilAlt"/> {{ $ts.editWidgets }}</button>
</template>
<XWidgets :edit="editMode" :widgets="$store.reactiveState.widgets.value" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/>
<button v-if="editMode" @click="editMode = false" class="_textButton" style="font-size: 0.9em;"><Fa :icon="faCheck"/> {{ $ts.editWidgetsExit }}</button>
<button v-else @click="editMode = true" class="_textButton" style="font-size: 0.9em;"><Fa :icon="faPencilAlt"/> {{ $ts.editWidgets }}</button>
</div>
</template>
<script lang="ts">
import { defineComponent, defineAsyncComponent } from 'vue';
import { v4 as uuid } from 'uuid';
import { faPencilAlt, faPlus, faBars, faTimes, faCheck } from '@fortawesome/free-solid-svg-icons';
import { widgets } from '@/widgets';
import XWidgets from '@/components/widgets.vue';
import * as os from '@/os';
import MkButton from '@/components/ui/button.vue';
export default defineComponent({
components: {
MkButton,
XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
XWidgets
},
emits: ['mounted'],
@ -48,62 +23,35 @@ export default defineComponent({
data() {
return {
editMode: false,
settings: {},
faPencilAlt, faPlus, faBars, faTimes, faCheck,
};
},
computed: {
widgets: {
get() {
return this.$store.reactiveState.widgets.value;
},
set(value) {
this.$store.set('widgets', value);
}
},
},
mounted() {
this.$emit('mounted', this.$el);
},
methods: {
widgetFunc(id) {
this.settings[id]();
},
async addWidget() {
const { canceled, result: widget } = await os.dialog({
type: null,
title: this.$ts.chooseWidget,
select: {
items: widgets.map(widget => ({
value: widget,
text: this.$t('_widgets.' + widget),
}))
},
showCancelButton: true
});
if (canceled) return;
this.$store.set('widgets', [...this.$store.state.widgets, {
name: widget,
id: uuid(),
addWidget(widget) {
this.$store.set('widgets', [{
...widget,
place: null,
data: {}
}]);
}, ...this.$store.state.widgets]);
},
removeWidget(widget) {
this.$store.set('widgets', this.$store.state.widgets.filter(w => w.id != widget.id));
},
saveWidget(id, data) {
updateWidget({ id, data }) {
this.$store.set('widgets', this.$store.state.widgets.map(w => w.id === id ? {
...w,
data: data
} : w));
},
updateWidgets(widgets) {
this.$store.set('widgets', widgets);
}
}
});
@ -129,35 +77,5 @@ export default defineComponent({
> .add {
margin: 0 auto;
}
.customize-container {
margin: 8px 0;
> header {
position: relative;
line-height: 32px;
> .handle {
padding: 0 8px;
cursor: move;
}
> .remove {
position: absolute;
top: 0;
right: 0;
padding: 0 8px;
line-height: 32px;
}
}
> div {
padding: 8px;
> * {
pointer-events: none;
}
}
}
}
</style>

View File

@ -54,7 +54,8 @@ export default defineComponent({
async run() {
this.logs = [];
const aiscript = new AiScript(createAiScriptEnv({
storageKey: 'widget'
storageKey: 'widget',
token: this.$i?.token,
}), {
in: (q) => {
return new Promise(ok => {

View File

@ -45,7 +45,8 @@ export default defineComponent({
methods: {
async run() {
const aiscript = new AiScript(createAiScriptEnv({
storageKey: 'scratchpad'
storageKey: 'widget',
token: this.$i?.token,
}), {
in: (q) => {
return new Promise(ok => {

View File

@ -1,4 +1,4 @@
# Creating plugins
# New Plugin
If you use the plugin function of the Misskey web client, you can expand the web client with a variety of different functionality. This page will list metadata definitions for plugin creation as well as an AiScript API reference for plugins.
## Metadata

View File

@ -1,2 +1,2 @@
# フォロー
# Ikuti
ユーザーをフォローすると、タイムラインにそのユーザーの投稿が表示されるようになります。ただし、他のユーザーに対する返信は含まれません。 ユーザーをフォローするには、ユーザーページの「フォロー」ボタンをクリックします。フォローを解除するには、もう一度クリックします。

View File

@ -1,4 +1,4 @@
# AiScript
## 関数
## Funzione
デフォルトで値渡しです。

View File

@ -1,7 +1,7 @@
# プラグインの作成
Misskey Webクライアントのプラグイン機能を使うと、クライアントを拡張し、様々な機能を追加できます。 ここではプラグインの作成にあたってのメタデータ定義や、AiScript APIリファレンスを掲載します。
## メタデータ
## Metadato
プラグインは、AiScriptのメタデータ埋め込み機能を使って、デフォルトとしてプラグインのメタデータを定義する必要があります。 メタデータは次のプロパティを含むオブジェクトです。
### name

View File

@ -1,2 +1,2 @@
# フォロー
# Seiguiti
ユーザーをフォローすると、タイムラインにそのユーザーの投稿が表示されるようになります。ただし、他のユーザーに対する返信は含まれません。 ユーザーをフォローするには、ユーザーページの「フォロー」ボタンをクリックします。フォローを解除するには、もう一度クリックします。

View File

@ -1,6 +1,6 @@
# Pages
## 変数
## Variabili
変数を使うことで動的なページを作成できます。テキスト内で <b>{ 変数名 }</b> と書くとそこに変数の値を埋め込めます。例えば <b>Hello { thing } world!</b> というテキストで、変数(thing)の値が <b>ai</b> だった場合、テキストは <b>Hello ai world!</b> になります。
変数の評価(値を算出すること)は上から下に行われるので、ある変数の中で自分より下の変数を参照することはできません。例えば上から <b>A、B、C</b> と3つの変数を定義したとき、<b>C</b>の中で<b>A</b><b>B</b>を参照することはできますが、<b>A</b>の中で<b>B</b><b>C</b>を参照することはできません。

View File

@ -1,4 +1,4 @@
# リアクション
# Reazione
他の人のノートに、絵文字を付けて簡単にあなたの反応を伝えられる機能です。 リアクションするには、ノートの + アイコンをクリックしてピッカーを表示し、絵文字を選択します。 リアクションには[カスタム絵文字](./custom-emoji)も使用できます。
## リアクションピッカーのカスタマイズ

View File

@ -31,7 +31,7 @@
**ストリームでのやり取りはすべてJSONです。**
## チャンネル
## Canale
MisskeyのストリーミングAPIにはチャンネルという概念があります。これは、送受信する情報を分離するための仕組みです。 Misskeyのストリームに接続しただけでは、まだリアルタイムでタイムラインの投稿を受信したりはできません。 ストリーム上でチャンネルに接続することで、様々な情報を受け取ったり情報を送信したりすることができるようになります。
### チャンネルに接続する

View File

@ -1,4 +1,4 @@
# テーマ
# Tema
テーマを設定して、Misskeyクライアントの見た目を変更できます。
@ -61,8 +61,8 @@
* 関数(後述)
* `:{関数名}<{引数}<{色}`
#### 定数
#### Costante
「CSS変数として出力はしたくないが、他のCSS変数の値として使いまわしたい」値があるときは、定数を使うと便利です。 キー名を`$`で始めると、そのキーはCSS変数として出力されません。
#### 関数
#### Funzione
wip

View File

@ -2,10 +2,10 @@
https://docs.google.com/spreadsheets/d/1lxQ2ugKrhz58Bg96HTDK_2F98BUritkMyIiBkOByjHA/edit?usp=sharing
## ホーム
## Home
自分のフォローしているユーザーの投稿
## ローカル
## Locale
全てのローカルユーザーの「ホーム」指定されていない投稿
## ソーシャル

View File

@ -18,21 +18,21 @@ APIを使い始めるには、まずアクセストークンを取得する必
### アプリケーション利用者にアクセストークンの発行をリクエストする
アプリケーション利用者のアクセストークンを取得するには、以下の手順で発行をリクエストします。
#### Step 1
#### Крок 1
UUIDを生成する。以後これをセッションIDと呼びます。
Створити UUID.以後これをセッションIDと呼びます。
> このセッションIDは毎回生成し、使いまわさないようにしてください。
#### Step 2
#### Крок 2
`{_URL_}/miauth/{session}`をユーザーのブラウザで表示させる。`{session}`の部分は、セッションIDに置き換えてください。
> 例: `{_URL_}/miauth/c1f6d42b-468b-4fd2-8274-e58abdedef6f`
表示する際、URLにクエリパラメータとしていくつかのオプションを設定できます:
* `name` ... アプリケーション名
* `name` ... Назва додатка
* > 例: `MissDeck`
* `icon` ... アプリケーションのアイコン画像URL
* `icon` ... URL піктограми додатка
* > 例: `https://missdeck.example.com/icon.png`
* `callback` ... 認証が終わった後にリダイレクトするURL
* > 例: `https://missdeck.example.com/callback`
@ -42,7 +42,7 @@ UUIDを生成する。以後これをセッションIDと呼びます。
* 要求する権限を`,`で区切って列挙します
* どのような権限があるかは[APIリファレンス](/api-doc)で確認できます
#### Step 3
#### Крок 3
ユーザーが発行を許可した後、`{_URL_}/api/miauth/{session}/check`にPOSTリクエストすると、レスポンスとしてアクセストークンを含むJSONが返ります。
レスポンスに含まれるプロパティ:
@ -51,8 +51,8 @@ UUIDを生成する。以後これをセッションIDと呼びます。
[「APIの使い方」へ進む](#APIの使い方)
## APIの使い方
**APIはすべてPOSTで、リクエスト/レスポンスともにJSON形式です。RESTではありません。** アクセストークンは、`i`というパラメータ名でリクエストに含めます。
## Використання API
**APIはすべてPOSTで、リクエスト/レスポンスともにJSON形式です。Підтримка REST відсутня.** アクセストークンは、`i`というパラメータ名でリクエストに含めます。
* [APIリファレンス](/api-doc)
* [ストリーミングAPI](./stream)
* [Довідник API](/api-doc)
* [Потокове API](./stream)

View File

@ -1,4 +1,4 @@
# プラグインの作成
# Створення плагінів
Misskey Webクライアントのプラグイン機能を使うと、クライアントを拡張し、様々な機能を追加できます。 ここではプラグインの作成にあたってのメタデータ定義や、AiScript APIリファレンスを掲載します。
## Метадані
@ -34,7 +34,7 @@ Misskey Webクライアントのプラグイン機能を使うと、クライア
#### default
設定のデフォルト値
## APIリファレンス
## Довідник API
AiScript標準で組み込まれているAPIは掲載しません。
### Mk:dialog(title text type)

View File

@ -1,4 +1,4 @@
# ストリーミングAPI
# Потокове API
ストリーミングAPIを使うと、リアルタイムで様々な情報(例えばタイムラインに新しい投稿が流れてきた、メッセージが届いた、フォローされた、など)を受け取ったり、様々な操作を行ったりすることができます。

View File

@ -4,7 +4,6 @@ import { User } from '../models/entities/user';
import { UserListJoinings, UserGroupJoinings } from '../models';
import parseAcct from './acct/parse';
import { getFullApAccount } from './convert-host';
import { ensure } from '../prelude/ensure';
export async function checkHitAntenna(antenna: Antenna, note: Note, noteUser: User, followers: User['id'][]): Promise<boolean> {
if (note.visibility === 'specified') return false;
@ -24,7 +23,7 @@ export async function checkHitAntenna(antenna: Antenna, note: Note, noteUser: Us
if (!listUsers.includes(note.userId)) return false;
} else if (antenna.src === 'group') {
const joining = await UserGroupJoinings.findOne(antenna.userGroupJoiningId!).then(ensure);
const joining = await UserGroupJoinings.findOneOrFail(antenna.userGroupJoiningId!);
const groupUsers = (await UserGroupJoinings.find({
userGroupId: joining.userGroupId

View File

@ -1,10 +1,9 @@
import { fetchMeta } from './fetch-meta';
import { ILocalUser } from '../models/entities/user';
import { Users } from '../models';
import { ensure } from '../prelude/ensure';
export async function fetchProxyAccount(): Promise<ILocalUser | null> {
const meta = await fetchMeta();
if (meta.proxyAccountId == null) return null;
return await Users.findOne(meta.proxyAccountId).then(ensure) as ILocalUser;
return await Users.findOneOrFail(meta.proxyAccountId) as ILocalUser;
}

View File

@ -1,14 +1,9 @@
// Notice: Service Workerでも使用します
export class I18n<T extends Record<string, any>> {
public locale: T;
constructor(locale: T) {
this.locale = locale;
if (_DEV_) {
console.log('i18n', this.locale);
}
//#region BIND
this.t = this.t.bind(this);
//#endregion
@ -20,12 +15,6 @@ export class I18n<T extends Record<string, any>> {
try {
let str = key.split('.').reduce((o, i) => o[i], this.locale) as string;
if (_DEV_) {
if (!str.includes('{')) {
console.warn(`i18n: '${key}' has no any arg. so ref prop directly instead of call this method.`);
}
}
if (args) {
for (const [k, v] of Object.entries(args)) {
str = str.replace(`{${k}}`, v);
@ -33,11 +22,7 @@ export class I18n<T extends Record<string, any>> {
}
return str;
} catch (e) {
if (_DEV_) {
console.warn(`missing localization '${key}'`);
return `⚠'${key}'⚠`;
}
return key;
}
}

View File

@ -4,6 +4,8 @@ import { User } from './user';
import { Page } from './page';
import { notificationTypes } from '../../types';
// TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも
// ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン
@Entity()
export class UserProfile {
@PrimaryColumn(id())
@ -41,6 +43,11 @@ export class UserProfile {
value: string;
}[];
@Column('varchar', {
length: 32, nullable: true,
})
public lang: string | null;
@Column('varchar', {
length: 512, nullable: true,
comment: 'Remote URL of the user.'
@ -63,6 +70,11 @@ export class UserProfile {
})
public emailVerified: boolean;
@Column('jsonb', {
default: ['follow', 'receiveFollowRequest', 'groupInvited']
})
public emailNotificationTypes: string[];
@Column('varchar', {
length: 128, nullable: true,
})

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