From 615fedd64d269c896410713409847ecc4b1c9671 Mon Sep 17 00:00:00 2001 From: syuilo <syuilotan@yahoo.co.jp> Date: Tue, 27 Oct 2020 16:16:59 +0900 Subject: [PATCH] Instance Ticker --- locales/ja-JP.yml | 6 + .../1603776877564-instance-theme-color.ts | 14 ++ migration/1603781553011-instance-favicon.ts | 14 ++ src/client/components/instance-ticker.vue | 61 +++++++ src/client/components/note.vue | 8 + src/client/components/sidebar.vue | 2 +- src/client/config.ts | 2 +- src/client/pages/settings/general.vue | 11 ++ src/client/store.ts | 1 + src/client/ui/visitor.vue | 4 +- src/models/entities/instance.ts | 10 ++ src/models/repositories/user.ts | 10 +- src/server/web/views/base.pug | 1 + src/services/fetch-instance-metadata.ts | 161 +++++++++++++++--- 14 files changed, 280 insertions(+), 25 deletions(-) create mode 100644 migration/1603776877564-instance-theme-color.ts create mode 100644 migration/1603781553011-instance-favicon.ts create mode 100644 src/client/components/instance-ticker.vue diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index f4c22d1fff..58e9132cf3 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -597,6 +597,12 @@ openInNewTab: "æ–°ã—ã„タブã§é–‹ã" openInSideView: "サイドビューã§é–‹ã" defaultNavigationBehaviour: "デフォルトã®ãƒŠãƒ“ゲーション" editTheseSettingsMayBreakAccount: "ã“れらã®è¨å®šã‚’編集ã™ã‚‹ã¨ã‚¢ã‚«ã‚¦ãƒ³ãƒˆãŒç ´æã™ã‚‹å¯èƒ½æ€§ãŒã‚ã‚Šã¾ã™ã€‚" +instanceTicker: "ノートã®ã‚¤ãƒ³ã‚¹ã‚¿ãƒ³ã‚¹æƒ…å ±" + +_instanceTicker: + none: "表示ã—ãªã„" + remote: "リモートユーザーã«è¡¨ç¤º" + always: "常ã«è¡¨ç¤º" _serverDisconnectedBehavior: reload: "自動ã§ãƒªãƒãƒ¼ãƒ‰" diff --git a/migration/1603776877564-instance-theme-color.ts b/migration/1603776877564-instance-theme-color.ts new file mode 100644 index 0000000000..80c9d516fc --- /dev/null +++ b/migration/1603776877564-instance-theme-color.ts @@ -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"`); + } + +} diff --git a/migration/1603781553011-instance-favicon.ts b/migration/1603781553011-instance-favicon.ts new file mode 100644 index 0000000000..d748c43f5e --- /dev/null +++ b/migration/1603781553011-instance-favicon.ts @@ -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"`); + } + +} diff --git a/src/client/components/instance-ticker.vue b/src/client/components/instance-ticker.vue new file mode 100644 index 0000000000..9447e6d4c3 --- /dev/null +++ b/src/client/components/instance-ticker.vue @@ -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> diff --git a/src/client/components/note.vue b/src/client/components/note.vue index 8ddb01f733..4e31aec12e 100644 --- a/src/client/components/note.vue +++ b/src/client/components/note.vue @@ -40,6 +40,7 @@ <MkAvatar class="avatar" :user="appearNote.user"/> <div class="main"> <XNoteHeader class="header" :note="appearNote" :mini="true"/> + <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> <div class="body" ref="noteBody"> <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"/> @@ -139,6 +140,7 @@ export default defineComponent({ XCwButton, XPoll, MkUrlPreview: defineAsyncComponent(() => import('@/components/url-preview.vue')), + MkInstanceTicker: defineAsyncComponent(() => import('@/components/instance-ticker.vue')), }, inject: { @@ -258,6 +260,12 @@ export default defineComponent({ } else { 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; } }, diff --git a/src/client/components/sidebar.vue b/src/client/components/sidebar.vue index 3ceb1f9b8d..bd19cf1a74 100644 --- a/src/client/components/sidebar.vue +++ b/src/client/components/sidebar.vue @@ -246,7 +246,7 @@ export default defineComponent({ icon: faQuestionCircle, }, { type: 'link', - text: this.$t('aboutX', { x: instanceName || host }), + text: this.$t('aboutX', { x: instanceName }), to: '/about', icon: faInfoCircle, }, { diff --git a/src/client/config.ts b/src/client/config.ts index ac8d7d9528..e0d2fd1deb 100644 --- a/src/client/config.ts +++ b/src/client/config.ts @@ -12,5 +12,5 @@ export const lang = localStorage.getItem('lang'); export const langs = _LANGS_; export const getLocale = async () => Object.fromEntries((await entries(clientDb.i18n)) as [string, string][]); 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'; diff --git a/src/client/pages/settings/general.vue b/src/client/pages/settings/general.vue index d61d8620e7..0db571ff14 100644 --- a/src/client/pages/settings/general.vue +++ b/src/client/pages/settings/general.vue @@ -51,6 +51,12 @@ <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> </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 class="_card _vMargin"> @@ -169,6 +175,11 @@ export default defineComponent({ 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: { get() { return this.$store.state.device.enableInfiniteScroll; }, set(value) { this.$store.commit('device/set', { key: 'enableInfiniteScroll', value }); } diff --git a/src/client/store.ts b/src/client/store.ts index 5dc35bb42e..5c6c71d4f2 100644 --- a/src/client/store.ts +++ b/src/client/store.ts @@ -77,6 +77,7 @@ export const defaultDeviceSettings = { enableInfiniteScroll: true, useBlurEffectForModal: true, sidebarDisplay: 'full', // full, icon, hide + instanceTicker: 'remote', // none, remote, always roomGraphicsQuality: 'medium', roomUseOrthographicCamera: true, deckColumnAlign: 'left', diff --git a/src/client/ui/visitor.vue b/src/client/ui/visitor.vue index 8b7dfd7911..8a3c19b631 100644 --- a/src/client/ui/visitor.vue +++ b/src/client/ui/visitor.vue @@ -4,11 +4,11 @@ <MkA class="link" to="/">{{ $t('home') }}</MkA> <MkA class="link" to="/announcements">{{ $t('announcements') }}</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> <div class="banner" :style="{ backgroundImage: `url(${ $store.state.instance.meta.bannerUrl })` }"> - <h1>{{ instanceName || host }}</h1> + <h1>{{ instanceName }}</h1> </div> <div class="contents" ref="contents" :class="{ wallpaper }"> diff --git a/src/models/entities/instance.ts b/src/models/entities/instance.ts index 5fedfc0956..7c8719e06a 100644 --- a/src/models/entities/instance.ts +++ b/src/models/entities/instance.ts @@ -163,6 +163,16 @@ export class Instance { }) 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', { nullable: true, }) diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts index 7ea4d42bcb..4ac7c6d85d 100644 --- a/src/models/repositories/user.ts +++ b/src/models/repositories/user.ts @@ -1,7 +1,7 @@ import $ from 'cafy'; import { EntityRepository, Repository, In, Not } from 'typeorm'; 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 config from '../../config'; import { SchemaType } from '../../misc/schema'; @@ -181,6 +181,14 @@ export class UserRepository extends Repository<User> { isModerator: user.isModerator || falsy, isBot: user.isBot || 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({ diff --git a/src/server/web/views/base.pug b/src/server/web/views/base.pug index d3f0106ac1..9652d29dbb 100644 --- a/src/server/web/views/base.pug +++ b/src/server/web/views/base.pug @@ -11,6 +11,7 @@ html meta(name='application-name' content='Misskey') meta(name='referrer' content='origin') meta(name='theme-color' content='#86b300') + meta(name='theme-color-orig' content='#86b300') meta(property='og:site_name' content= instanceName || 'Misskey') meta(name='viewport' content='width=device-width, initial-scale=1') link(rel='icon' href= icon || '/favicon.ico') diff --git a/src/services/fetch-instance-metadata.ts b/src/services/fetch-instance-metadata.ts index 41fef859c9..487421816a 100644 --- a/src/services/fetch-instance-metadata.ts +++ b/src/services/fetch-instance-metadata.ts @@ -1,4 +1,4 @@ -import { JSDOM } from 'jsdom'; +import { DOMWindow, JSDOM } from 'jsdom'; import fetch from 'node-fetch'; import { getJson, getHtml, getAgentByUrl } from '../misc/fetch'; 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} ...`); try { - const [info, icon] = await Promise.all([ + const [info, dom, manifest] = await Promise.all([ 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}`); @@ -34,18 +43,18 @@ export async function fetchInstanceMetadata(instance: Instance): Promise<void> { } as Record<string, any>; if (info) { - updates.softwareName = info.software.name.toLowerCase(); - updates.softwareVersion = info.software.version; + updates.softwareName = info.software?.name.toLowerCase(); + updates.softwareVersion = info.software?.version; 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.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email || null) : null : null; } - if (icon) { - updates.iconUrl = icon; - } + if (name) updates.name = name; + 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); @@ -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} ...`); try { @@ -100,8 +127,8 @@ async function fetchNodeinfo(instance: Instance): Promise<Record<string, any>> { } } -async function fetchIconUrl(instance: Instance): Promise<string | null> { - logger.info(`Fetching icon URL of ${instance.host} ...`); +async function fetchDom(instance: Instance): Promise<DOMWindow['document']> { + logger.info(`Fetching HTML of ${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 doc = window.document; - 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'); + return doc; +} - const href = hrefAppleTouchIconPrecomposed || hrefAppleTouchIcon || hrefIcon; +async function fetchManifest(instance: Instance): Promise<Record<string, any> | null> { + const url = 'https://' + instance.host; - if (href) { - return (new URL(href, url)).href; - } + const manifestUrl = url + '/manifest.json'; + + 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 favicon = await fetch(faviconUrl, { @@ -133,3 +167,90 @@ async function fetchIconUrl(instance: Instance): Promise<string | 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; +} -- GitLab