Instance Ticker

This commit is contained in:
syuilo 2020-10-27 16:16:59 +09:00
parent d62474969b
commit 403a0dcca6
14 changed files with 280 additions and 25 deletions

View file

@ -597,6 +597,12 @@ openInNewTab: "新しいタブで開く"
openInSideView: "サイドビューで開く" openInSideView: "サイドビューで開く"
defaultNavigationBehaviour: "デフォルトのナビゲーション" defaultNavigationBehaviour: "デフォルトのナビゲーション"
editTheseSettingsMayBreakAccount: "これらの設定を編集するとアカウントが破損する可能性があります。" editTheseSettingsMayBreakAccount: "これらの設定を編集するとアカウントが破損する可能性があります。"
instanceTicker: "ノートのインスタンス情報"
_instanceTicker:
none: "表示しない"
remote: "リモートユーザーに表示"
always: "常に表示"
_serverDisconnectedBehavior: _serverDisconnectedBehavior:
reload: "自動でリロード" reload: "自動でリロード"

View file

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

View file

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

View file

@ -0,0 +1,61 @@
<template>
<div class="hpaizdrt" :style="bg">
<img v-if="info.faviconUrl" class="icon" :src="info.faviconUrl"/>
<span class="name">{{ info.name }}</span>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { instanceName } from '@/config';
export default defineComponent({
props: {
instance: {
type: Object,
required: false
},
},
data() {
return {
info: this.instance || {
faviconUrl: '/favicon.ico',
name: instanceName,
themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content
}
}
},
computed: {
bg(): any {
return this.info.themeColor ? {
background: `linear-gradient(90deg, ${this.info.themeColor}, ${this.info.themeColor + '00'})`
} : null;
}
}
});
</script>
<style lang="scss" scoped>
.hpaizdrt {
$height: 1.1rem;
height: $height;
border-radius: 4px 0 0 4px;
overflow: hidden;
color: #fff;
> .icon {
height: 100%;
}
> .name {
margin-left: 4px;
line-height: $height;
font-size: 0.9em;
vertical-align: top;
font-weight: bold;
}
}
</style>

View file

@ -40,6 +40,7 @@
<MkAvatar class="avatar" :user="appearNote.user"/> <MkAvatar class="avatar" :user="appearNote.user"/>
<div class="main"> <div class="main">
<XNoteHeader class="header" :note="appearNote" :mini="true"/> <XNoteHeader class="header" :note="appearNote" :mini="true"/>
<MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/>
<div class="body" ref="noteBody"> <div class="body" ref="noteBody">
<p v-if="appearNote.cw != null" class="cw"> <p v-if="appearNote.cw != null" class="cw">
<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/> <Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$store.state.i" :custom-emojis="appearNote.emojis"/>
@ -139,6 +140,7 @@ export default defineComponent({
XCwButton, XCwButton,
XPoll, XPoll,
MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')),
MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')),
}, },
inject: { inject: {
@ -258,6 +260,12 @@ export default defineComponent({
} else { } else {
return null; return null;
} }
},
showTicker() {
if (this.$store.state.device.instanceTicker === 'always') return true;
if (this.$store.state.device.instanceTicker === 'remote' && this.appearNote.user.instance) return true;
return false;
} }
}, },

View file

@ -246,7 +246,7 @@ export default defineComponent({
icon: faQuestionCircle, icon: faQuestionCircle,
}, { }, {
type: 'link', type: 'link',
text: this.$t('aboutX', { x: instanceName || host }), text: this.$t('aboutX', { x: instanceName }),
to: '/about', to: '/about',
icon: faInfoCircle, icon: faInfoCircle,
}, { }, {

View file

@ -12,5 +12,5 @@ export const lang = localStorage.getItem('lang');
export const langs = _LANGS_; export const langs = _LANGS_;
export const getLocale = async () => Object.fromEntries((await entries(clientDb.i18n)) as [string, string][]); export const getLocale = async () => Object.fromEntries((await entries(clientDb.i18n)) as [string, string][]);
export const version = _VERSION_; export const version = _VERSION_;
export const instanceName = siteName === 'Misskey' ? null : siteName; export const instanceName = siteName === 'Misskey' ? host : siteName;
export const deckmode = localStorage.getItem('deckmode') === 'true'; export const deckmode = localStorage.getItem('deckmode') === 'true';

View file

@ -51,6 +51,12 @@
<MkRadio v-model="fontSize" value="large"><span style="font-size: 18px;">Aa</span></MkRadio> <MkRadio v-model="fontSize" value="large"><span style="font-size: 18px;">Aa</span></MkRadio>
<MkRadio v-model="fontSize" value="veryLarge"><span style="font-size: 20px;">Aa</span></MkRadio> <MkRadio v-model="fontSize" value="veryLarge"><span style="font-size: 20px;">Aa</span></MkRadio>
</div> </div>
<div class="_content">
<div>{{ $t('instanceTicker') }}</div>
<MkRadio v-model="instanceTicker" value="none">{{ $t('_instanceTicker.none') }}</MkRadio>
<MkRadio v-model="instanceTicker" value="remote">{{ $t('_instanceTicker.remote') }}</MkRadio>
<MkRadio v-model="instanceTicker" value="always">{{ $t('_instanceTicker.always') }}</MkRadio>
</div>
</section> </section>
<section class="_card _vMargin"> <section class="_card _vMargin">
@ -169,6 +175,11 @@ export default defineComponent({
set(value) { this.$store.commit('device/set', { key: 'chatOpenBehavior', value }); } set(value) { this.$store.commit('device/set', { key: 'chatOpenBehavior', value }); }
}, },
instanceTicker: {
get() { return this.$store.state.device.instanceTicker; },
set(value) { this.$store.commit('device/set', { key: 'instanceTicker', value }); }
},
enableInfiniteScroll: { enableInfiniteScroll: {
get() { return this.$store.state.device.enableInfiniteScroll; }, get() { return this.$store.state.device.enableInfiniteScroll; },
set(value) { this.$store.commit('device/set', { key: 'enableInfiniteScroll', value }); } set(value) { this.$store.commit('device/set', { key: 'enableInfiniteScroll', value }); }

View file

@ -77,6 +77,7 @@ export const defaultDeviceSettings = {
enableInfiniteScroll: true, enableInfiniteScroll: true,
useBlurEffectForModal: true, useBlurEffectForModal: true,
sidebarDisplay: 'full', // full, icon, hide sidebarDisplay: 'full', // full, icon, hide
instanceTicker: 'remote', // none, remote, always
roomGraphicsQuality: 'medium', roomGraphicsQuality: 'medium',
roomUseOrthographicCamera: true, roomUseOrthographicCamera: true,
deckColumnAlign: 'left', deckColumnAlign: 'left',

View file

@ -4,11 +4,11 @@
<MkA class="link" to="/">{{ $t('home') }}</MkA> <MkA class="link" to="/">{{ $t('home') }}</MkA>
<MkA class="link" to="/announcements">{{ $t('announcements') }}</MkA> <MkA class="link" to="/announcements">{{ $t('announcements') }}</MkA>
<MkA class="link" to="/channels">{{ $t('channel') }}</MkA> <MkA class="link" to="/channels">{{ $t('channel') }}</MkA>
<MkA class="link" to="/about">{{ $t('aboutX', { x: instanceName || host }) }}</MkA> <MkA class="link" to="/about">{{ $t('aboutX', { x: instanceName }) }}</MkA>
</header> </header>
<div class="banner" :style="{ backgroundImage: `url(${ $store.state.instance.meta.bannerUrl })` }"> <div class="banner" :style="{ backgroundImage: `url(${ $store.state.instance.meta.bannerUrl })` }">
<h1>{{ instanceName || host }}</h1> <h1>{{ instanceName }}</h1>
</div> </div>
<div class="contents" ref="contents" :class="{ wallpaper }"> <div class="contents" ref="contents" :class="{ wallpaper }">

View file

@ -163,6 +163,16 @@ export class Instance {
}) })
public iconUrl: string | null; public iconUrl: string | null;
@Column('varchar', {
length: 256, nullable: true, default: null,
})
public faviconUrl: string | null;
@Column('varchar', {
length: 64, nullable: true, default: null,
})
public themeColor: string | null;
@Column('timestamp with time zone', { @Column('timestamp with time zone', {
nullable: true, nullable: true,
}) })

View file

@ -1,7 +1,7 @@
import $ from 'cafy'; import $ from 'cafy';
import { EntityRepository, Repository, In, Not } from 'typeorm'; import { EntityRepository, Repository, In, Not } from 'typeorm';
import { User, ILocalUser, IRemoteUser } from '../entities/user'; import { User, ILocalUser, IRemoteUser } from '../entities/user';
import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes, ChannelFollowings } from '..'; import { Emojis, Notes, NoteUnreads, FollowRequests, Notifications, MessagingMessages, UserNotePinings, Followings, Blockings, Mutings, UserProfiles, UserSecurityKeys, UserGroupJoinings, Pages, Announcements, AnnouncementReads, Antennas, AntennaNotes, ChannelFollowings, Instances } from '..';
import { ensure } from '../../prelude/ensure'; import { ensure } from '../../prelude/ensure';
import config from '../../config'; import config from '../../config';
import { SchemaType } from '../../misc/schema'; import { SchemaType } from '../../misc/schema';
@ -181,6 +181,14 @@ export class UserRepository extends Repository<User> {
isModerator: user.isModerator || falsy, isModerator: user.isModerator || falsy,
isBot: user.isBot || falsy, isBot: user.isBot || falsy,
isCat: user.isCat || falsy, isCat: user.isCat || falsy,
instance: user.host ? Instances.findOne({ host: user.host }).then(instance => instance ? {
name: instance.name,
softwareName: instance.softwareName,
softwareVersion: instance.softwareVersion,
iconUrl: instance.iconUrl,
faviconUrl: instance.faviconUrl,
themeColor: instance.themeColor,
} : undefined) : undefined,
// カスタム絵文字添付 // カスタム絵文字添付
emojis: user.emojis.length > 0 ? Emojis.find({ emojis: user.emojis.length > 0 ? Emojis.find({

View file

@ -11,6 +11,7 @@ html
meta(name='application-name' content='Misskey') meta(name='application-name' content='Misskey')
meta(name='referrer' content='origin') meta(name='referrer' content='origin')
meta(name='theme-color' content='#86b300') meta(name='theme-color' content='#86b300')
meta(name='theme-color-orig' content='#86b300')
meta(property='og:site_name' content= instanceName || 'Misskey') meta(property='og:site_name' content= instanceName || 'Misskey')
meta(name='viewport' content='width=device-width, initial-scale=1') meta(name='viewport' content='width=device-width, initial-scale=1')
link(rel='icon' href= icon || '/favicon.ico') link(rel='icon' href= icon || '/favicon.ico')

View file

@ -1,4 +1,4 @@
import { JSDOM } from 'jsdom'; import { DOMWindow, JSDOM } from 'jsdom';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import { getJson, getHtml, getAgentByUrl } from '../misc/fetch'; import { getJson, getHtml, getAgentByUrl } from '../misc/fetch';
import { Instance } from '../models/entities/instance'; import { Instance } from '../models/entities/instance';
@ -22,9 +22,18 @@ export async function fetchInstanceMetadata(instance: Instance): Promise<void> {
logger.info(`Fetching metadata of ${instance.host} ...`); logger.info(`Fetching metadata of ${instance.host} ...`);
try { try {
const [info, icon] = await Promise.all([ const [info, dom, manifest] = await Promise.all([
fetchNodeinfo(instance).catch(() => null), fetchNodeinfo(instance).catch(() => null),
fetchIconUrl(instance).catch(() => null), fetchDom(instance).catch(() => null),
fetchManifest(instance).catch(() => null),
]);
const [favicon, icon, themeColor, name, description] = await Promise.all([
fetchFaviconUrl(instance).catch(() => null),
fetchIconUrl(instance, dom, manifest).catch(() => null),
getThemeColor(dom, manifest).catch(() => null),
getSiteName(info, dom, manifest).catch(() => null),
getDescription(info, dom, manifest).catch(() => null),
]); ]);
logger.succ(`Successfuly fetched metadata of ${instance.host}`); logger.succ(`Successfuly fetched metadata of ${instance.host}`);
@ -34,18 +43,18 @@ export async function fetchInstanceMetadata(instance: Instance): Promise<void> {
} as Record<string, any>; } as Record<string, any>;
if (info) { if (info) {
updates.softwareName = info.software.name.toLowerCase(); updates.softwareName = info.software?.name.toLowerCase();
updates.softwareVersion = info.software.version; updates.softwareVersion = info.software?.version;
updates.openRegistrations = info.openRegistrations; updates.openRegistrations = info.openRegistrations;
updates.name = info.metadata ? (info.metadata.nodeName || info.metadata.name || null) : null;
updates.description = info.metadata ? (info.metadata.nodeDescription || info.metadata.description || null) : null;
updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name || null) : null : null; updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name || null) : null : null;
updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email || null) : null : null; updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email || null) : null : null;
} }
if (icon) { if (name) updates.name = name;
updates.iconUrl = icon; if (description) updates.description = description;
} if (icon || favicon) updates.iconUrl = icon || favicon;
if (favicon) updates.faviconUrl = favicon;
if (themeColor) updates.themeColor = themeColor;
await Instances.update(instance.id, updates); await Instances.update(instance.id, updates);
@ -57,7 +66,25 @@ export async function fetchInstanceMetadata(instance: Instance): Promise<void> {
} }
} }
async function fetchNodeinfo(instance: Instance): Promise<Record<string, any>> { type NodeInfo = {
openRegistrations?: any;
software?: {
name?: any;
version?: any;
};
metadata?: {
name?: any;
nodeName?: any;
nodeDescription?: any;
description?: any;
maintainer?: {
name?: any;
email?: any;
};
};
};
async function fetchNodeinfo(instance: Instance): Promise<NodeInfo> {
logger.info(`Fetching nodeinfo of ${instance.host} ...`); logger.info(`Fetching nodeinfo of ${instance.host} ...`);
try { try {
@ -100,8 +127,8 @@ async function fetchNodeinfo(instance: Instance): Promise<Record<string, any>> {
} }
} }
async function fetchIconUrl(instance: Instance): Promise<string | null> { async function fetchDom(instance: Instance): Promise<DOMWindow['document']> {
logger.info(`Fetching icon URL of ${instance.host} ...`); logger.info(`Fetching HTML of ${instance.host} ...`);
const url = 'https://' + instance.host; const url = 'https://' + instance.host;
@ -110,16 +137,23 @@ async function fetchIconUrl(instance: Instance): Promise<string | null> {
const { window } = new JSDOM(html); const { window } = new JSDOM(html);
const doc = window.document; const doc = window.document;
const hrefAppleTouchIconPrecomposed = doc.querySelector('link[rel="apple-touch-icon-precomposed"]')?.getAttribute('href'); return doc;
const hrefAppleTouchIcon = doc.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href'); }
const hrefIcon = doc.querySelector('link[rel="icon"]')?.getAttribute('href');
const href = hrefAppleTouchIconPrecomposed || hrefAppleTouchIcon || hrefIcon; async function fetchManifest(instance: Instance): Promise<Record<string, any> | null> {
const url = 'https://' + instance.host;
if (href) { const manifestUrl = url + '/manifest.json';
return (new URL(href, url)).href;
}
const manifest = await getJson(manifestUrl);
return manifest;
}
async function fetchFaviconUrl(instance: Instance): Promise<string | null> {
logger.info(`Fetching favicon URL of ${instance.host} ...`);
const url = 'https://' + instance.host;
const faviconUrl = url + '/favicon.ico'; const faviconUrl = url + '/favicon.ico';
const favicon = await fetch(faviconUrl, { const favicon = await fetch(faviconUrl, {
@ -133,3 +167,90 @@ async function fetchIconUrl(instance: Instance): Promise<string | null> {
return null; return null;
} }
async function fetchIconUrl(instance: Instance, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (doc) {
const url = 'https://' + instance.host;
const hrefAppleTouchIconPrecomposed = doc.querySelector('link[rel="apple-touch-icon-precomposed"]')?.getAttribute('href');
const hrefAppleTouchIcon = doc.querySelector('link[rel="apple-touch-icon"]')?.getAttribute('href');
const hrefIcon = doc.querySelector('link[rel="icon"]')?.getAttribute('href');
const href = hrefAppleTouchIconPrecomposed || hrefAppleTouchIcon || hrefIcon;
if (href) {
return (new URL(href, url)).href;
}
}
if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) {
const url = 'https://' + instance.host;
return (new URL(manifest.icons[0].src, url)).href;
}
return null;
}
async function getThemeColor(doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (doc) {
const themeColor = doc.querySelector('meta[name="theme-color"]')?.getAttribute('content');
if (themeColor) {
return themeColor;
}
}
if (manifest) {
return manifest.theme_color;
}
return null;
}
async function getSiteName(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (info && info.metadata) {
if (info.metadata.nodeName || info.metadata.name) {
return info.metadata.nodeName || info.metadata.name;
}
}
if (doc) {
const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content');
if (og) {
return og;
}
}
if (manifest) {
return manifest?.name || manifest?.short_name;
}
return null;
}
async function getDescription(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
if (info && info.metadata) {
if (info.metadata.nodeDescription || info.metadata.description) {
return info.metadata.nodeDescription || info.metadata.description;
}
}
if (doc) {
const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content');
if (meta) {
return meta;
}
const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content');
if (og) {
return og;
}
}
if (manifest) {
return manifest?.name || manifest?.short_name;
}
return null;
}