summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authorsyuilo <4439005+syuilo@users.noreply.github.com>2025-02-25 20:51:23 +0900
committerGitHub <noreply@github.com>2025-02-25 11:51:23 +0000
commit2b6638e1607e5c44eb6f6dc987e9e893927f1829 (patch)
treef3efba7ca978bd5cf7d32202a014ffb73ac719f6 /packages/frontend/src
parentchore(config): migrate renovate config (#15550) (diff)
downloadmisskey-2b6638e1607e5c44eb6f6dc987e9e893927f1829.tar.gz
misskey-2b6638e1607e5c44eb6f6dc987e9e893927f1829.tar.bz2
misskey-2b6638e1607e5c44eb6f6dc987e9e893927f1829.zip
feat: google analytics (#15451)
* wip backend * wip frontend * build misskey-js * implement control panel * fix * introduce analytics wrapper * spdx * Update analytics.ts * Update common.ts * wip * wip * wip * wip * wip * Update CHANGELOG.md --------- Co-authored-by: kakkokari-gtyih <67428053+kakkokari-gtyih@users.noreply.github.com>
Diffstat (limited to 'packages/frontend/src')
-rw-r--r--packages/frontend/src/analytics.ts107
-rw-r--r--packages/frontend/src/boot/common.ts16
-rw-r--r--packages/frontend/src/components/MkPageWindow.vue16
-rw-r--r--packages/frontend/src/pages/admin/external-services.vue53
-rw-r--r--packages/frontend/src/router/main.ts9
5 files changed, 185 insertions, 16 deletions
diff --git a/packages/frontend/src/analytics.ts b/packages/frontend/src/analytics.ts
new file mode 100644
index 0000000000..e07a4e9258
--- /dev/null
+++ b/packages/frontend/src/analytics.ts
@@ -0,0 +1,107 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import * as Misskey from 'misskey-js';
+import type { AnalyticsInstance, AnalyticsPlugin } from 'analytics';
+
+/**
+ * analytics moduleを読み込まなくても動作するようにするためのラッパー
+ */
+class AnalyticsProxy implements AnalyticsInstance {
+ private analytics?: AnalyticsInstance;
+
+ constructor(analytics?: AnalyticsInstance) {
+ if (analytics) {
+ this.analytics = analytics;
+ }
+ }
+
+ public setAnalytics(analytics: AnalyticsInstance) {
+ if (this.analytics) {
+ throw new Error('Analytics instance already exists.');
+ }
+ this.analytics = analytics;
+ }
+
+ public identify(...args: Parameters<AnalyticsInstance['identify']>) {
+ return this.analytics?.identify(...args) ?? Promise.resolve();
+ }
+
+ public track(...args: Parameters<AnalyticsInstance['track']>) {
+ return this.analytics?.track(...args) ?? Promise.resolve();
+ }
+
+ public page(...args: Parameters<AnalyticsInstance['page']>) {
+ return this.analytics?.page(...args) ?? Promise.resolve();
+ }
+
+ public user(...args: Parameters<AnalyticsInstance['user']>) {
+ return this.analytics?.user(...args) ?? Promise.resolve();
+ }
+
+ public reset(...args: Parameters<AnalyticsInstance['reset']>) {
+ return this.analytics?.reset(...args) ?? Promise.resolve();
+ }
+
+ public ready(...args: Parameters<AnalyticsInstance['ready']>) {
+ return this.analytics?.ready(...args) ?? function () { void 0; };
+ }
+
+ public on(...args: Parameters<AnalyticsInstance['on']>) {
+ return this.analytics?.on(...args) ?? function () { void 0; };
+ }
+
+ public once(...args: Parameters<AnalyticsInstance['once']>) {
+ return this.analytics?.once(...args) ?? function () { void 0; };
+ }
+
+ public getState(...args: Parameters<AnalyticsInstance['getState']>) {
+ return this.analytics?.getState(...args) ?? Promise.resolve();
+ }
+
+ public get storage() {
+ return this.analytics?.storage ?? {
+ getItem: () => null,
+ setItem: () => void 0,
+ removeItem: () => void 0,
+ };
+ }
+
+ public get plugins() {
+ return this.analytics?.plugins ?? {
+ enable: (p, c) => Promise.resolve(c ? c() : void 0),
+ disable: (p, c) => Promise.resolve(c ? c() : void 0),
+ };
+ }
+}
+
+export const analytics = new AnalyticsProxy();
+
+export async function initAnalytics(instance: Misskey.entities.MetaDetailed) {
+ // アナリティクスプロバイダに関する設定がひとつもない場合は、アナリティクスモジュールを読み込まない
+ if (!instance.googleAnalyticsMeasurementId) {
+ return;
+ }
+
+ const { default: Analytics } = await import('analytics');
+ const plugins: AnalyticsPlugin[] = [];
+
+ // Google Analytics
+ if (instance.googleAnalyticsMeasurementId) {
+ const { default: googleAnalytics } = await import('@analytics/google-analytics');
+
+ plugins.push(googleAnalytics({
+ measurementIds: [instance.googleAnalyticsMeasurementId],
+ debug: _DEV_,
+ }));
+ }
+
+ analytics.setAnalytics(Analytics({
+ app: 'misskey',
+ version: _VERSION_,
+ debug: _DEV_,
+ plugins,
+ }));
+}
diff --git a/packages/frontend/src/boot/common.ts b/packages/frontend/src/boot/common.ts
index 1d8e40a12d..d09b98efe0 100644
--- a/packages/frontend/src/boot/common.ts
+++ b/packages/frontend/src/boot/common.ts
@@ -4,9 +4,9 @@
*/
import { computed, watch, version as vueVersion } from 'vue';
-import type { App } from 'vue';
import { compareVersions } from 'compare-versions';
import { version, lang, updateLocale, locale } from '@@/js/config.js';
+import type { App } from 'vue';
import widgets from '@/widgets/index.js';
import directives from '@/directives/index.js';
import components from '@/components/index.js';
@@ -21,6 +21,7 @@ import { reloadChannel } from '@/scripts/unison-reload.js';
import { getUrlWithoutLoginId } from '@/scripts/login-id.js';
import { getAccountFromId } from '@/scripts/get-account-from-id.js';
import { deckStore } from '@/ui/deck/deck-store.js';
+import { analytics, initAnalytics } from '@/analytics.js';
import { miLocalStorage } from '@/local-storage.js';
import { fetchCustomEmojis } from '@/custom-emojis.js';
import { setupRouter } from '@/router/main.js';
@@ -241,6 +242,19 @@ export async function common(createVue: () => App<Element>) {
await fetchCustomEmojis();
} catch (err) { /* empty */ }
+ // analytics
+ fetchInstanceMetaPromise.then(async () => {
+ await initAnalytics(instance);
+
+ if ($i) {
+ analytics.identify($i.id);
+ }
+
+ analytics.page({
+ path: window.location.pathname,
+ });
+ });
+
const app = createVue();
setupRouter(app, createMainRouter);
diff --git a/packages/frontend/src/components/MkPageWindow.vue b/packages/frontend/src/components/MkPageWindow.vue
index 1420b9c26f..e725d2a15d 100644
--- a/packages/frontend/src/components/MkPageWindow.vue
+++ b/packages/frontend/src/components/MkPageWindow.vue
@@ -32,6 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue';
import { url } from '@@/js/config.js';
import { getScrollContainer } from '@@/js/scroll.js';
+import type { PageMetadata } from '@/scripts/page-metadata.js';
import RouterView from '@/components/global/RouterView.vue';
import MkWindow from '@/components/MkWindow.vue';
import { popout as _popout } from '@/scripts/popout.js';
@@ -39,11 +40,11 @@ import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
import { useScrollPositionManager } from '@/nirax.js';
import { i18n } from '@/i18n.js';
import { provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
-import type { PageMetadata } from '@/scripts/page-metadata.js';
import { openingWindowsCount } from '@/os.js';
import { claimAchievement } from '@/scripts/achievements.js';
import { useRouterFactory } from '@/router/supplier.js';
import { mainRouter } from '@/router/main.js';
+import { analytics } from '@/analytics.js';
const props = defineProps<{
initialPath: string;
@@ -99,6 +100,14 @@ windowRouter.addListener('replace', ctx => {
history.value.push({ path: ctx.path, key: ctx.key });
});
+windowRouter.addListener('change', ctx => {
+ console.log('windowRouter: change', ctx.path);
+ analytics.page({
+ path: ctx.path,
+ title: ctx.path,
+ });
+});
+
windowRouter.init();
provide('router', windowRouter);
@@ -160,6 +169,11 @@ function popout() {
useScrollPositionManager(() => getScrollContainer(contents.value), windowRouter);
onMounted(() => {
+ analytics.page({
+ path: props.initialPath,
+ title: props.initialPath,
+ });
+
openingWindowsCount.value++;
if (openingWindowsCount.value >= 3) {
claimAchievement('open3windows');
diff --git a/packages/frontend/src/pages/admin/external-services.vue b/packages/frontend/src/pages/admin/external-services.vue
index 91f41166e9..a312ecce12 100644
--- a/packages/frontend/src/pages/admin/external-services.vue
+++ b/packages/frontend/src/pages/admin/external-services.vue
@@ -8,20 +8,34 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
- <MkFolder>
- <template #label>DeepL Translation</template>
+ <div class="_gaps_m">
+ <MkFolder>
+ <template #label>Google Analytics<span class="_beta">{{ i18n.ts.beta }}</span></template>
- <div class="_gaps_m">
- <MkInput v-model="deeplAuthKey">
- <template #prefix><i class="ti ti-key"></i></template>
- <template #label>DeepL Auth Key</template>
- </MkInput>
- <MkSwitch v-model="deeplIsPro">
- <template #label>Pro account</template>
- </MkSwitch>
- <MkButton primary @click="save_deepl">Save</MkButton>
- </div>
- </MkFolder>
+ <div class="_gaps_m">
+ <MkInput v-model="googleAnalyticsMeasurementId">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>Measurement ID</template>
+ </MkInput>
+ <MkButton primary @click="save_googleAnalytics">Save</MkButton>
+ </div>
+ </MkFolder>
+
+ <MkFolder>
+ <template #label>DeepL Translation</template>
+
+ <div class="_gaps_m">
+ <MkInput v-model="deeplAuthKey">
+ <template #prefix><i class="ti ti-key"></i></template>
+ <template #label>DeepL Auth Key</template>
+ </MkInput>
+ <MkSwitch v-model="deeplIsPro">
+ <template #label>Pro account</template>
+ </MkSwitch>
+ <MkButton primary @click="save_deepl">Save</MkButton>
+ </div>
+ </MkFolder>
+ </div>
</FormSuspense>
</MkSpacer>
</MkStickyContainer>
@@ -44,10 +58,13 @@ import MkFolder from '@/components/MkFolder.vue';
const deeplAuthKey = ref<string>('');
const deeplIsPro = ref<boolean>(false);
+const googleAnalyticsMeasurementId = ref<string>('');
+
async function init() {
const meta = await misskeyApi('admin/meta');
- deeplAuthKey.value = meta.deeplAuthKey;
+ deeplAuthKey.value = meta.deeplAuthKey ?? '';
deeplIsPro.value = meta.deeplIsPro;
+ googleAnalyticsMeasurementId.value = meta.googleAnalyticsMeasurementId ?? '';
}
function save_deepl() {
@@ -59,6 +76,14 @@ function save_deepl() {
});
}
+function save_googleAnalytics() {
+ os.apiWithDialog('admin/update-meta', {
+ googleAnalyticsMeasurementId: googleAnalyticsMeasurementId.value,
+ }).then(() => {
+ fetchInstance(true);
+ });
+}
+
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
diff --git a/packages/frontend/src/router/main.ts b/packages/frontend/src/router/main.ts
index 4379b6a316..3932a8bac8 100644
--- a/packages/frontend/src/router/main.ts
+++ b/packages/frontend/src/router/main.ts
@@ -7,6 +7,7 @@ import { EventEmitter } from 'eventemitter3';
import type { IRouter, Resolved, RouteDef, RouterEvent, RouterFlag } from '@/nirax.js';
import type { App, ShallowRef } from 'vue';
+import { analytics } from '@/analytics.js';
/**
* {@link Router}による画面遷移を可能とするために{@link mainRouter}をセットアップする。
@@ -29,6 +30,14 @@ export function setupRouter(app: App, routerFactory: ((path: string) => IRouter)
window.history.replaceState({ key: ctx.key }, '', ctx.path);
});
+ mainRouter.addListener('change', ctx => {
+ console.log('mainRouter: change', ctx.path);
+ analytics.page({
+ path: ctx.path,
+ title: ctx.path,
+ });
+ });
+
mainRouter.init();
setMainRouter(mainRouter);