This commit is contained in:
syuilo 2020-12-26 10:01:32 +09:00
parent 4fce5d8066
commit 9d81d06853
26 changed files with 844 additions and 174 deletions

View file

@ -1,7 +1,9 @@
{
"globals": {
"_DEV_": false,
"_LANG_": false,
"_LANGS_": false,
"_LOCALE_": false,
"_VERSION_": false,
"_ENV_": false,
"_PERF_PREFIX_": false,

View file

@ -1,4 +1,6 @@
declare const _LANGS_: string[];
declare const _LANG_: string;
declare const _LANGS_: string[][];
declare const _LOCALE_: Record<string, any>;
declare const _VERSION_: string;
declare const _ENV_: string;
declare const _DEV_: boolean;

View file

@ -2,11 +2,11 @@
<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="$emit('closed')">
<template #header>
<Fa :icon="faExclamationCircle" style="margin-right: 0.5em;"/>
<i18n-t keypath="reportAbuseOf" tag="span">
<I18n src="reportAbuseOf" tag="span">
<template #name>
<b><MkAcct :user="user"/></b>
</template>
</i18n-t>
</I18n>
</template>
<div class="dpvffvvy">
<div class="_section">

View file

@ -6,19 +6,19 @@
<div class="status">
<div>
<Fa :icon="faUsers" fixed-width/>
<i18n-t keypath="_channel.usersCount" tag="span" style="margin-left: 4px;">
<I18n src="_channel.usersCount" tag="span" style="margin-left: 4px;">
<template #n>
<b>{{ channel.usersCount }}</b>
</template>
</i18n-t>
</I18n>
</div>
<div>
<Fa :icon="faPencilAlt" fixed-width/>
<i18n-t keypath="_channel.notesCount" tag="span" style="margin-left: 4px;">
<I18n src="_channel.notesCount" tag="span" style="margin-left: 4px;">
<template #n>
<b>{{ channel.notesCount }}</b>
</template>
</i18n-t>
</I18n>
</div>
</div>
</div>

View file

@ -0,0 +1,15 @@
import { h, Fragment, defineComponent } from 'vue';
import type { SetupContext, VNodeChild, RenderFunction } from 'vue';
export default defineComponent({
props: {
src: {
type: String,
required: true
},
},
render() {
// TODO
return h('span', this.src);
}
});

View file

@ -9,6 +9,7 @@ import userName from './global/user-name.vue';
import ellipsis from './global/ellipsis.vue';
import time from './global/time.vue';
import url from './global/url.vue';
import i18n from './global/i18n';
import loading from './global/loading.vue';
import error from './global/error.vue';
@ -24,4 +25,5 @@ export default function(app: App) {
app.component('MkUrl', url);
app.component('MkLoading', loading);
app.component('MkError', error);
app.component('I18n', i18n);
}

View file

@ -16,13 +16,13 @@
<div class="renote" v-if="isRenote">
<MkAvatar class="avatar" :user="note.user"/>
<Fa :icon="faRetweet"/>
<i18n-t keypath="renotedBy" tag="span">
<I18n src="renotedBy" tag="span">
<template #user>
<MkA class="name" :to="userPage(note.user)" v-user-preview="note.userId">
<MkUserName :user="note.user"/>
</MkA>
</template>
</i18n-t>
</I18n>
<div class="info">
<button class="_button time" @click="showRenoteMenu()" ref="renoteTime">
<Fa class="dropdownIcon" v-if="isMyRenote" :icon="faEllipsisH"/>
@ -90,13 +90,13 @@
<XSub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/>
</div>
<div v-else class="_panel muted" @click="muted = false">
<i18n-t keypath="userSaysSomething" tag="small">
<I18n src="userSaysSomething" tag="small">
<template #name>
<MkA class="name" :to="userPage(appearNote.user)" v-user-preview="appearNote.userId">
<MkUserName :user="appearNote.user"/>
</MkA>
</template>
</i18n-t>
</I18n>
</div>
</template>

View file

@ -38,9 +38,9 @@
</MkInput>
<label v-if="meta.tosUrl" class="tou">
<input type="checkbox" v-model="ToSAgreement">
<i18n-t keypath="agreeTo">
<I18n src="agreeTo">
<a :href="meta.tosUrl" class="_link" target="_blank">{{ $t('tos') }}</a>
</i18n-t>
</I18n>
</label>
<captcha v-if="meta.enableHcaptcha" class="captcha" provider="hcaptcha" ref="hcaptcha" v-model:value="hCaptchaResponse" :sitekey="meta.hcaptchaSiteKey"/>
<captcha v-if="meta.enableRecaptcha" class="captcha" provider="grecaptcha" ref="recaptcha" v-model:value="reCaptchaResponse" :sitekey="meta.recaptchaSiteKey"/>

View file

@ -1,5 +1,3 @@
import { clientDb, entries } from './db';
const address = new URL(location.href);
const siteName = (document.querySelector('meta[property="og:site_name"]') as HTMLMetaElement)?.content;
@ -8,9 +6,9 @@ export const hostname = address.hostname;
export const url = address.origin;
export const apiUrl = url + '/api';
export const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss://') + '/streaming';
export const lang = localStorage.getItem('lang');
export const lang = _LANG_;
export const langs = _LANGS_;
export const getLocale = async () => Object.fromEntries((await entries(clientDb.i18n)) as [string, string][]);
export const locale = _LOCALE_;
export const version = _VERSION_;
export const instanceName = siteName === 'Misskey' ? host : siteName;
export const ui = localStorage.getItem('ui');

View file

@ -1,36 +1,49 @@
import { createI18n } from 'vue-i18n';
import { clientDb, get, count } from './db';
import { setI18nContexts } from '@/scripts/set-i18n-contexts';
import { version, langs, getLocale } from '@/config';
import { markRaw } from 'vue';
import { locale } from '@/config';
let _lang = localStorage.getItem('lang');
export class I18n<T extends Record<string, any>> {
public locale: T;
if (_lang == null) {
if (langs.map(x => x[0]).includes(navigator.language)) {
_lang = navigator.language;
} else {
_lang = langs.map(x => x[0]).find(x => x.split('-')[0] == navigator.language);
constructor(locale: T) {
this.locale = locale;
if (_lang == null) {
// Fallback
_lang = 'en-US';
if (_DEV_) {
console.log('i18n', this.locale);
}
//#region BIND
this.t = this.t.bind(this);
//#endregion
}
localStorage.setItem('lang', _lang);
// string にしているのは、ドット区切りでのパス指定を許可するため
// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
public t(key: string, args?: Record<string, any>): string {
try {
let str = key.split('.').reduce((o, i) => o[i], this.locale) as string;
if (args) {
for (const [k, v] of Object.entries(args)) {
str = str.replace(`{${k}}`, v);
}
}
return str;
} catch (e) {
if (_DEV_) {
console.warn(`missing localization '${key}'`);
return `⚠'${key}'⚠`;
}
return key;
}
}
}
export const lang = _lang;
export const i18n = markRaw(new I18n(locale));
export const locale = await count(clientDb.i18n).then(async n => {
if (n === 0) return await setI18nContexts(_lang, version);
if ((await get('_version_', clientDb.i18n) !== version)) return await setI18nContexts(_lang, version, true);
return await getLocale();
});
export const i18n = createI18n({
sync: false,
locale: _lang,
messages: { [_lang]: locale }
});
// このファイルに書きたくないけどここに書かないと何故かVeturが認識しない
declare module '@vue/runtime-core' {
interface ComponentCustomProperties {
$t: typeof i18n['t'];
$ts: typeof i18n['locale'];
}
}

View file

@ -40,11 +40,11 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
import widgets from '@/widgets';
import directives from '@/directives';
import components from '@/components';
import { version, ui } from '@/config';
import { version, ui, lang } from '@/config';
import { router } from '@/router';
import { applyTheme } from '@/scripts/theme';
import { isDeviceDarkmode } from '@/scripts/is-device-darkmode';
import { i18n, lang } from '@/i18n';
import { i18n } from '@/i18n';
import { stream, isMobile, dialog } from '@/os';
import * as sound from '@/scripts/sound';
import { $i, refreshAccount, login, updateAccount, signout } from '@/account';
@ -53,6 +53,8 @@ import { fetchInstance, instance } from '@/instance';
console.info(`Misskey v${version}`);
window.clearTimeout(window.mkBootTimer);
if (_DEV_) {
console.warn('Development mode!!!');
@ -175,10 +177,11 @@ app.config.globalProperties = {
$i,
$store: defaultStore,
$instance: instance,
$t: i18n.t,
$ts: i18n.locale,
};
app.use(router);
app.use(i18n);
// eslint-disable-next-line vue/component-definition-name-casing
app.component('Fa', FontAwesomeIcon);

View file

@ -10,8 +10,8 @@
</div>
<div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner">
<div class="status">
<div><Fa :icon="faUsers" fixed-width/><i18n-t keypath="_channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></i18n-t></div>
<div><Fa :icon="faPencilAlt" fixed-width/><i18n-t keypath="_channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></i18n-t></div>
<div><Fa :icon="faUsers" fixed-width/><I18n src="_channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div>
<div><Fa :icon="faPencilAlt" fixed-width/><I18n src="_channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div>
</div>
<div class="fade"></div>
</div>

View file

@ -35,18 +35,18 @@
<div>
<MkRadio v-model="game.bw" value="random" @update:modelValue="updateSettings('bw')">{{ $t('random') }}</MkRadio>
<MkRadio v-model="game.bw" :value="'1'" @update:modelValue="updateSettings('bw')">
<i18n-t keypath="_reversi.blackIs" tag="span">
<I18n src="_reversi.blackIs" tag="span">
<template #name>
<b><MkUserName :user="game.user1"/></b>
</template>
</i18n-t>
</I18n>
</MkRadio>
<MkRadio v-model="game.bw" :value="'2'" @update:modelValue="updateSettings('bw')">
<i18n-t keypath="_reversi.blackIs" tag="span">
<I18n src="_reversi.blackIs" tag="span">
<template #name>
<b><MkUserName :user="game.user2"/></b>
</template>
</i18n-t>
</I18n>
</MkRadio>
</div>
</div>

View file

@ -46,11 +46,11 @@
</div>
<div class="sazhgisb" v-else>
<h1>
<i18n-t keypath="waitingFor" tag="span">
<I18n src="waitingFor" tag="span">
<template #x>
<b><MkUserName :user="matching"/></b>
</template>
</i18n-t>
</I18n>
<MkEllipsis/>
</h1>
<div class="cancel">

View file

@ -45,14 +45,14 @@
<div v-if="data && !$i.twoFactorEnabled">
<ol style="margin: 0; padding: 0 0 0 1em;">
<li>
<i18n-t keypath="_2fa.step1" tag="span">
<I18n src="_2fa.step1" tag="span">
<template #a>
<a href="https://authy.com/" rel="noopener" target="_blank" class="_link">Authy</a>
</template>
<template #b>
<a href="https://support.google.com/accounts/answer/1066447" rel="noopener" target="_blank" class="_link">Google Authenticator</a>
</template>
</i18n-t>
</I18n>
</li>
<li>{{ $t('_2fa.step2') }}<br><img :src="data.qr"></li>
<li>{{ $t('_2fa.step3') }}<br>

View file

@ -6,11 +6,11 @@
<template #label>{{ $t('uiLanguage') }}</template>
<option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
<template #caption>
<i18n-t keypath="i18nInfo" tag="span">
<I18n src="i18nInfo" tag="span">
<template #link>
<MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink>
</template>
</i18n-t>
</I18n>
</template>
</FormSelect>

View file

@ -23,14 +23,14 @@
</div>
<div class="_content" v-else-if="tutorial === 4">
<div>{{ $t('_tutorial.step5_1') }}</div>
<i18n-t keypath="_tutorial.step5_2" tag="div">
<I18n src="_tutorial.step5_2" tag="div">
<template #featured>
<MkA class="_link" to="/featured">{{ $t('featured') }}</MkA>
</template>
<template #explore>
<MkA class="_link" to="/explore">{{ $t('explore') }}</MkA>
</template>
</i18n-t>
</I18n>
<div>{{ $t('_tutorial.step5_3') }}</div>
<small>{{ $t('_tutorial.step5_4') }}</small>
</div>
@ -41,11 +41,11 @@
</div>
<div class="_content" v-else-if="tutorial === 6">
<div>{{ $t('_tutorial.step7_1') }}</div>
<i18n-t keypath="_tutorial.step7_2" tag="div">
<I18n src="_tutorial.step7_2" tag="div">
<template #help>
<MkA class="_link" to="/docs">{{ $t('help') }}</MkA>
</template>
</i18n-t>
</I18n>
<div>{{ $t('_tutorial.step7_3') }}</div>
</div>

View file

@ -1,15 +0,0 @@
import { clientDb, clear, bulkSet } from '../db';
import { deepEntries, delimitEntry } from 'deep-entries';
export function setI18nContexts(lang: string, version: string, cleardb = false) {
return Promise.all([
cleardb ? clear(clientDb.i18n) : Promise.resolve(),
fetch(`/assets/locales/${lang}.${version}.json`)
])
.then(([, response]) => response.json())
.then(locale => {
const flatLocaleEntries = deepEntries(locale, delimitEntry) as [string, string][];
bulkSet(flatLocaleEntries, clientDb.i18n);
return Object.fromEntries(flatLocaleEntries);
});
}

View file

@ -177,7 +177,7 @@ export default defineComponent({
// TODO:
$columnMargin: 12px;
$deckMargin: 12px;
$deckMargin: $columnMargin;
--margin: var(--marginHalf);

117
src/server/web/boot.js Normal file
View file

@ -0,0 +1,117 @@
/**
* BOOT LOADER
* サーバーからレスポンスされるHTMLに埋め込まれるスクリプトで以下の役割を持ちます
* - バージョンやユーザーの言語に基づいて適切なメインスクリプトを読み込む
* - キャッシュされたコンパイル済みテーマを適用する
* - クライアントの設定値に基づいて対応するHTMLクラスやCSS変数を設定する
* テーマやCSS変数をこの段階で設定するのはメインスクリプトが読み込まれる間もテーマを適用したいためです
* : webpackは介さないためこのファイルではrequireやimportは使えません
*/
'use strict';
//#region Script
//#region Detect language
const supportedLangs = LANGS;
let lang = localStorage.getItem('lang');
if (lang == null || !supportedLangs.includes(lang)) {
if (supportedLangs.includes(navigator.language)) {
lang = navigator.language;
} else {
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
// Fallback
if (lang == null) lang = 'en-US';
}
}
//#endregion
const ver = localStorage.getItem('v') || VERSION;
const salt = localStorage.getItem('salt')
? `?salt=${localStorage.getItem('salt')}`
: '';
const head = document.getElementsByTagName('head')[0];
const script = document.createElement('script');
script.setAttribute('src', `/assets/app.${ver}.${lang}.js${salt}`);
script.setAttribute('async', 'true');
script.setAttribute('defer', 'true');
head.appendChild(script);
// 3秒経ってもスクリプトがロードされない場合はバージョンが古くて
// 404になっているせいかもしれないので、バージョンを確認して古ければ更新する
//
// 読み込まれたスクリプトからこのタイマーを解除できるように、
// グローバルにタイマーIDを代入しておく
window.mkBootTimer = window.setTimeout(async () => {
const res = await fetch('/api/meta', {
method: 'POST',
cache: 'no-cache'
});
const meta = await res.json();
if (meta.version != ver) {
localStorage.setItem('v', meta.version);
alert(
'Misskeyの新しいバージョンがあります。ページを再度読み込みします。' +
'\n\n' +
'New version of Misskey available. The page will be reloaded.');
refresh();
}
}, 3000);
//#endregion
//#region Theme
const theme = localStorage.getItem('theme');
if (theme) {
for (const [k, v] of Object.entries(JSON.parse(theme))) {
document.documentElement.style.setProperty(`--${k}`, v.toString());
// HTMLの theme-color 適用
if (k === 'htmlThemeColor') {
for (const tag of document.head.children) {
if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
tag.setAttribute('content', v);
break;
}
}
}
}
}
//#endregion
const fontSize = localStorage.getItem('fontSize');
if (fontSize) {
document.documentElement.classList.add('f-' + fontSize);
}
const useSystemFont = localStorage.getItem('useSystemFont');
if (useSystemFont) {
document.documentElement.classList.add('useSystemFont');
}
const wallpaper = localStorage.getItem('wallpaper');
if (wallpaper) {
document.documentElement.style.backgroundImage = `url(${wallpaper})`;
}
function refresh() {
// Random
localStorage.setItem('salt', Math.random().toString().substr(2, 8));
// Clear cache (service worker)
try {
navigator.serviceWorker.controller.postMessage('clear');
navigator.serviceWorker.getRegistrations().then(registrations => {
registrations.forEach(registration => registration.unregister());
});
} catch (e) {
console.error(e);
}
location.reload();
}

37
src/server/web/style.css Normal file
View file

@ -0,0 +1,37 @@
html {
background-color: var(--bg);
color: var(--fg);
}
#ini {
position: fixed;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: wait;
}
#ini > svg {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
width: 64px;
height: 64px;
animation: ini 0.6s infinite linear;
color: var(--accent);
fill: currentColor;
}
@keyframes ini {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View file

@ -33,76 +33,11 @@ html
block og
meta(property='og:image' content=img)
style.
html {
background-color: var(--bg);
color: var(--fg);
}
style
include ../style.css
#ini {
position: fixed;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: wait;
}
#ini > svg {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: auto;
width: 64px;
height: 64px;
animation: ini 0.6s infinite linear;
color: var(--accent);
fill: currentColor;
}
@keyframes ini {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
script(src=`/assets/app.${version}.js` async defer)
script.
const theme = localStorage.getItem('theme');
if (theme) {
for (const [k, v] of Object.entries(JSON.parse(theme))) {
document.documentElement.style.setProperty(`--${k}`, v.toString());
if (k === 'htmlThemeColor') {
for (const tag of document.head.children) {
if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
tag.setAttribute('content', v);
break;
}
}
}
}
}
const fontSize = localStorage.getItem('fontSize');
if (fontSize) {
document.documentElement.classList.add('f-' + fontSize);
}
const useSystemFont = localStorage.getItem('useSystemFont');
if (useSystemFont) {
document.documentElement.classList.add('useSystemFont');
}
const wallpaper = localStorage.getItem('wallpaper');
if (wallpaper) {
document.documentElement.style.backgroundImage = `url(${wallpaper})`;
}
script
include ../boot.js
body
noscript: p