Compare commits

..

3 Commits

Author SHA1 Message Date
Derek 7a4c331ff2 Update dependencies
We've been on 12.x for a while, probably a good idea
2023-08-21 03:01:02 -04:00
Derek 2d6018b487 Misc chatui fixes
- Better background color for other pages
- Fix focus behavior
- Fix channel creation route
- Remove unused page to avoid future confusion
2023-08-21 02:57:48 -04:00
Derek d12802cf25 Combine user and note tags 2023-08-21 02:08:22 -04:00
18 changed files with 3764 additions and 4088 deletions

View File

@ -74,7 +74,7 @@ export function toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMenti
hashtag(node) {
const a = doc.createElement('a');
a.href = `${config.url}/tags/${node.props.hashtag}`;
a.href = `${config.url}/explore/tags/${node.props.hashtag}`;
a.textContent = `#${node.props.hashtag}`;
a.setAttribute('rel', 'tag');
return a;

View File

@ -2,6 +2,6 @@ import config from '@/config/index.js';
export default (tag: string) => ({
type: 'Hashtag',
href: `${config.url}/tags/${encodeURIComponent(tag)}`,
href: `${config.url}/explore/tags/${encodeURIComponent(tag)}`,
name: `#${tag}`,
});

File diff suppressed because it is too large Load Diff

View File

@ -245,7 +245,7 @@ export default defineComponent({
case 'hashtag': {
return [h(MkA, {
key: Math.random(),
to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.props.hashtag)}`,
to: `/explore/tags/${encodeURIComponent(token.props.hashtag)}`,
style: 'color:var(--hashtag);',
}, `#${token.props.hashtag}`)];
}

View File

@ -0,0 +1,37 @@
<template>
<MkSpacer :content-max="1200">
<div>
<MkInput v-model="searchQuery" :debounce="true" type="search" class="_formBlock">
<template #prefix><i class="fas fa-search"></i></template>
<template #label>{{ i18n.ts.searchUser }}</template>
</MkInput>
<MkRadios v-model="searchOrigin" class="_formBlock">
<option value="combined">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.local }}</option>
<option value="remote">{{ i18n.ts.remote }}</option>
</MkRadios>
</div>
<XUserList v-if="searchQuery" ref="searchEl" class="_gap" :pagination="searchPagination"/>
</MkSpacer>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import MkInput from '@/components/form/input.vue';
import MkRadios from '@/components/form/radios.vue';
import { i18n } from '@/i18n';
import XUserList from '@/components/MkUserList.vue';
let searchQuery = $ref(null);
let searchOrigin = $ref('combined');
const searchPagination = {
endpoint: 'users/search' as const,
limit: 10,
params: computed(() => (searchQuery && searchQuery !== '') ? {
query: searchQuery,
origin: searchOrigin,
} : null),
};
</script>

View File

@ -0,0 +1,120 @@
<template>
<MkSpacer :content-max="1200">
<MkFolder ref="tagsEl" class="_gap">
<template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template>
<div class="vxjfqztj">
<MkA v-for="tag in tagsTrending" :key="'trend:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="trending">{{ tag.tag }}</MkA>
<MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA>
<MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="remote">{{ tag.tag }}</MkA>
</div>
</MkFolder>
<MkFolder v-if="tag != null" :key="`${tag}`" class="_gap">
<template #header><i class="fas fa-users fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.users }}</template>
<XUserList :pagination="tagUsers"/>
</MkFolder>
<MkFolder v-if="tag != null" :key="`${tag}`" class="_gap">
<template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.notes }}</template>
<MkSpacer :content-max="800">
<XNotes class="_content" :pagination="tagPosts"/>
</MkSpacer>
</MkFolder>
</MkSpacer>
</template>
<script lang="ts" setup>
import { computed, watch, onMounted } from 'vue';
import XUserList from '@/components/MkUserList.vue';
import XNotes from '@/components/MkNotes.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkTab from '@/components/MkTab.vue';
import number from '@/filters/number';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
import { useInterval } from '@/scripts/use-interval';
const props = defineProps<{
tag?: string;
}>();
let origin = $ref('local');
let tagsEl = $ref<InstanceType<typeof MkFolder>>();
let tagsLocal = $ref([]);
let tagsRemote = $ref([]);
let tagsTrending = $ref([]);
watch(() => props.tag, () => {
if (tagsEl) tagsEl.toggleContent(props.tag == null);
}, { immediate: true });
const tagUsers = $computed(() => ({
endpoint: 'hashtags/users' as const,
limit: 30,
params: {
tag: props.tag,
origin: 'combined',
sort: '+follower',
},
}));
const tagPosts = $computed(() => ({
endpoint: 'notes/search-by-tag' as const,
limit: 10,
params: {
tag: props.tag,
},
}));
const fetch = async () => {
const [local, remote, trending] = await Promise.all([
os.api('hashtags/list', {
sort: '+attachedLocalUsers',
attachedToLocalUserOnly: true,
limit: 15,
}),
os.api('hashtags/list', {
sort: '+attachedRemoteUsers',
attachedToRemoteUserOnly: true,
limit: 15,
}),
os.api('hashtags/trend')
]);
// Deduplicate (trends > local > remote)
tagsRemote = remote.filter((rTag) => (
!local.some((lTag) => lTag.tag == rTag.tag) && !trending.some((tTag) => tTag.tag == rTag.tag)
));
tagsLocal = local.filter((lTag) => (
!trending.some((tTag) => tTag.tag == lTag.tag)
));
tagsTrending = trending;
};
useInterval(fetch, 1000 * 60, {
immediate: true,
afterMounted: true,
});
</script>
<style lang="scss" scoped>
.vxjfqztj {
> * {
margin-right: 16px;
&.trending {
color: var(--hashtag);
}
&.local {
color: var(--fg);
}
&.remote {
color: var(--fgTransparent);
}
}
}
</style>

View File

@ -5,7 +5,6 @@
<option value="remote">{{ i18n.ts.remote }}</option>
</MkTab>
<div v-if="origin === 'local'">
<template v-if="tag == null">
<MkFolder class="_gap" persist-key="explore-pinned-users">
<template #header><i class="fas fa-bookmark fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.pinnedUsers }}</template>
<XUserList :pagination="pinnedUsers"/>
@ -22,24 +21,8 @@
<template #header><i class="fas fa-plus fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyRegisteredUsers }}</template>
<XUserList :pagination="recentlyRegisteredUsers"/>
</MkFolder>
</template>
</div>
<div v-else>
<MkFolder ref="tagsEl" :foldable="true" :expanded="false" class="_gap">
<template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularTags }}</template>
<div class="vxjfqztj">
<MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA>
<MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/explore/tags/${tag.tag}`">{{ tag.tag }}</MkA>
</div>
</MkFolder>
<MkFolder v-if="tag != null" :key="`${tag}`" class="_gap">
<template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template>
<XUserList :pagination="tagUsers"/>
</MkFolder>
<template v-if="tag == null">
<MkFolder class="_gap">
<template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.popularUsers }}</template>
<XUserList :pagination="popularUsersF"/>
@ -52,7 +35,6 @@
<template #header><i class="fas fa-rocket fa-fw" style="margin-right: 0.5em;"></i>{{ i18n.ts.recentlyDiscoveredUsers }}</template>
<XUserList :pagination="recentlyRegisteredUsersF"/>
</MkFolder>
</template>
</div>
</MkSpacer>
</template>
@ -67,28 +49,7 @@ import * as os from '@/os';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
const props = defineProps<{
tag?: string;
}>();
let origin = $ref('local');
let tagsEl = $ref<InstanceType<typeof MkFolder>>();
let tagsLocal = $ref([]);
let tagsRemote = $ref([]);
watch(() => props.tag, () => {
if (tagsEl) tagsEl.toggleContent(props.tag == null);
});
const tagUsers = $computed(() => ({
endpoint: 'hashtags/users' as const,
limit: 30,
params: {
tag: props.tag,
origin: 'combined',
sort: '+follower',
},
}));
const pinnedUsers = { endpoint: 'pinned-users' };
const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: {
@ -118,31 +79,4 @@ const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true,
origin: 'combined',
sort: '+createdAt',
} };
os.api('hashtags/list', {
sort: '+attachedLocalUsers',
attachedToLocalUserOnly: true,
limit: 30,
}).then(tags => {
tagsLocal = tags;
});
os.api('hashtags/list', {
sort: '+attachedRemoteUsers',
attachedToRemoteUserOnly: true,
limit: 30,
}).then(tags => {
tagsRemote = tags;
});
</script>
<style lang="scss" scoped>
.vxjfqztj {
> * {
margin-right: 16px;
&.local {
font-weight: bold;
}
}
}
</style>

View File

@ -8,22 +8,11 @@
<div v-else-if="tab === 'users'">
<XUsers/>
</div>
<div v-else-if="tab === 'search'">
<MkSpacer :content-max="1200">
<div>
<MkInput v-model="searchQuery" :debounce="true" type="search" class="_formBlock">
<template #prefix><i class="fas fa-search"></i></template>
<template #label>{{ i18n.ts.searchUser }}</template>
</MkInput>
<MkRadios v-model="searchOrigin" class="_formBlock">
<option value="combined">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.local }}</option>
<option value="remote">{{ i18n.ts.remote }}</option>
</MkRadios>
<div v-else-if="tab === 'tags'">
<XTags :tag="props.tag" />
</div>
<XUserList v-if="searchQuery" ref="searchEl" class="_gap" :pagination="searchPagination"/>
</MkSpacer>
<div v-else-if="tab === 'search'">
<XSearch />
</div>
</div>
</MkStickyContainer>
@ -33,37 +22,26 @@
import { computed, watch } from 'vue';
import XFeatured from './explore.featured.vue';
import XUsers from './explore.users.vue';
import XTags from './explore.tags.vue';
import XSearch from './explore.search.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkInput from '@/components/form/input.vue';
import MkRadios from '@/components/form/radios.vue';
import number from '@/filters/number';
import * as os from '@/os';
import { definePageMetadata } from '@/scripts/page-metadata';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
import XUserList from '@/components/MkUserList.vue';
import { useRouter } from '@/router';
const props = defineProps<{
tag?: string;
}>();
const router = useRouter();
let tab = $ref('featured');
let tagsEl = $ref<InstanceType<typeof MkFolder>>();
let searchQuery = $ref(null);
let searchOrigin = $ref('combined');
watch(() => props.tag, () => {
if (tagsEl) tagsEl.toggleContent(props.tag == null);
});
const searchPagination = {
endpoint: 'users/search' as const,
limit: 10,
params: computed(() => (searchQuery && searchQuery !== '') ? {
query: searchQuery,
origin: searchOrigin,
} : null),
};
if (props.tag != null) tab = 'tags';
}, { immediate: true });
const headerActions = $computed(() => []);
@ -75,6 +53,10 @@ const headerTabs = $computed(() => [{
key: 'users',
icon: 'fas fa-users',
title: i18n.ts.users,
}, {
key: 'tags',
icon: 'fas fa-hashtag',
title: i18n.ts.tags,
}, {
key: 'search',
title: i18n.ts.search,

View File

@ -1,35 +0,0 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="800">
<XNotes class="_content" :pagination="pagination"/>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import XNotes from '@/components/MkNotes.vue';
import { definePageMetadata } from '@/scripts/page-metadata';
const props = defineProps<{
tag: string;
}>();
const pagination = {
endpoint: 'notes/search-by-tag' as const,
limit: 10,
params: computed(() => ({
tag: props.tag,
})),
};
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
definePageMetadata(computed(() => ({
title: props.tag,
icon: 'fas fa-hashtag',
})));
</script>

View File

@ -203,9 +203,13 @@ export const defaultRoutes = [{
}, {
path: '/explore/tags/:tag',
component: page(() => import('./pages/explore.vue')),
name: 'explore',
globalCacheKey: 'explore',
}, {
path: '/explore',
component: page(() => import('./pages/explore.vue')),
name: 'explore',
globalCacheKey: 'explore',
}, {
path: '/search',
component: page(() => import('./pages/search.vue')),
@ -246,9 +250,6 @@ export const defaultRoutes = [{
icon: 'icon',
permission: 'permission',
},
}, {
path: '/tags/:tag',
component: page(() => import('./pages/tag.vue')),
}, {
path: '/pages/new',
component: page(() => import('./pages/page-editor/page-editor.vue')),

View File

@ -16,7 +16,7 @@ export async function search() {
}
if (q.startsWith('#')) {
mainRouter.value.push(`/tags/${encodeURIComponent(q.substr(1))}`);
mainRouter.value.push(`/explore/tags/${encodeURIComponent(q.substr(1))}`);
return;
}

View File

@ -113,6 +113,8 @@ const routes = [
hash: 'tlSrc',
component: $i ? page(() => import('./chat/pages/timeline.vue')) : page(() => import('../pages/welcome.vue')),
globalCacheKey: 'index',
}, {
...defaultRoutes.find((route) => route.path == '/channels/new'),
}, {
path: '/channels/:channelId',
props: { tlSrc: 'channel' },
@ -455,7 +457,6 @@ onMounted(() => {
min-width: 0;
height: 100vh;
position: relative;
background: var(--panel);
overflow: auto;
}

View File

@ -1,271 +0,0 @@
<template>
<div v-if="channel" class="hhizbblb">
<div v-if="date" class="info">
<MkInfo>{{ $ts.showingPastTimeline }} <button class="_textButton clear" @click="timetravel()">{{ $ts.clear }}</button></MkInfo>
</div>
<div ref="body" class="tl">
<div v-if="queue > 0" class="new" :style="{ width: width + 'px', bottom: bottom + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ $ts.newNoteRecived }}</button></div>
<XNotes ref="tl" v-follow="true" class="tl" :pagination="pagination" @queue="queueUpdated"/>
</div>
<div class="bottom">
<div v-if="typers.length > 0" class="typers">
<I18n :src="$ts.typingUsers" text-tag="span" class="users">
<template #users>
<b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b>
</template>
</I18n>
<MkEllipsis/>
</div>
<XPostForm :channel="channel"/>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, markRaw } from 'vue';
import * as Misskey from 'misskey-js';
import XNotes from '../notes.vue';
import * as os from '@/os';
import { stream } from '@/stream';
import * as sound from '@/scripts/sound';
import { scrollToBottom, getScrollPosition, getScrollContainer } from '@/scripts/scroll';
import follow from '@/directives/follow-append';
import XPostForm from '../post-form.vue';
import MkInfo from '@/components/ui/info.vue';
import * as symbols from '@/symbols';
export default defineComponent({
components: {
XNotes,
XPostForm,
MkInfo,
},
directives: {
follow
},
provide() {
return {
inChannel: true
};
},
props: {
channelId: {
type: String,
required: true
},
},
data() {
return {
channel: null as Misskey.entities.Channel | null,
connection: null,
pagination: null,
baseQuery: {
includeMyRenotes: this.$store.state.showMyRenotes,
includeRenotedMyNotes: this.$store.state.showRenotedMyNotes,
includeLocalRenotes: this.$store.state.showLocalRenotes
},
queue: 0,
width: 0,
top: 0,
bottom: 0,
typers: [],
date: null,
[symbols.PAGE_INFO]: computed(() => ({
title: this.channel ? this.channel.name : '-',
subtitle: this.channel ? this.channel.description : '-',
icon: 'fas fa-satellite-dish',
actions: [{
icon: this.channel?.isFollowing ? 'fas fa-star' : 'far fa-star',
text: this.channel?.isFollowing ? this.$ts.unfollow : this.$ts.follow,
highlighted: this.channel?.isFollowing,
handler: this.toggleChannelFollow
}, {
icon: 'fas fa-search',
text: this.$ts.inChannelSearch,
handler: this.inChannelSearch
}, {
icon: 'fas fa-calendar-alt',
text: this.$ts.jumpToSpecifiedDate,
handler: this.timetravel
}]
})),
};
},
watch: {
channelId() {
this.connection.dispose();
this.$refs.tl.reload();
this.setup();
},
},
created() {
this.setup();
},
mounted() {
},
beforeUnmount() {
this.connection.dispose();
},
methods: {
async setup() {
const prepend = note => {
(this.$refs.tl as any).prepend(note);
this.$emit('note');
sound.play(note.userId === this.$i.id ? 'noteMy' : 'note');
};
this.connection = markRaw(stream.useChannel('channel', {
channelId: this.channelId
}));
this.connection.on('note', prepend);
this.connection.on('typers', typers => {
this.typers = this.$i ? typers.filter(u => u.id !== this.$i.id) : typers;
});
this.pagination = {
endpoint: 'channels/timeline',
reversed: true,
limit: 10,
params: init => ({
channelId: this.channelId,
untilDate: this.date?.getTime(),
...this.baseQuery
})
};
this.channel = await os.api('channels/show', { channelId: this.channelId });
},
focus() {
this.$refs.body.focus();
},
goTop() {
const container = getScrollContainer(this.$refs.body);
container.scrollTop = 0;
},
queueUpdated(q) {
if (this.$refs.body.offsetWidth !== 0) {
const rect = this.$refs.body.getBoundingClientRect();
this.width = this.$refs.body.offsetWidth;
this.top = rect.top;
this.bottom = this.$refs.body.offsetHeight;
}
this.queue = q;
},
async inChannelSearch() {
const { canceled, result: query } = await os.inputText({
title: this.$ts.inChannelSearch,
});
if (canceled || query == null || query === '') return;
router.push(`/search?q=${encodeURIComponent(query)}&channel=${this.channelId}`);
},
async toggleChannelFollow() {
if (this.channel.isFollowing) {
await os.apiWithDialog('channels/unfollow', {
channelId: this.channelId
});
this.channel.isFollowing = false;
} else {
await os.apiWithDialog('channels/follow', {
channelId: this.channelId
});
this.channel.isFollowing = true;
}
},
openChannelMenu(ev) {
os.popupMenu([{
text: this.$ts.copyUrl,
icon: 'fas fa-link',
action: () => {
copyToClipboard(`${url}/channels/${this.currentChannel.id}`);
}
}], ev.currentTarget || ev.target);
},
timetravel(date?: Date) {
this.date = date;
this.$refs.tl.reload();
}
}
});
</script>
<style lang="scss" scoped>
.hhizbblb {
display: flex;
flex-direction: column;
flex: 1;
overflow: auto;
> .info {
padding: 16px 16px 0 16px;
}
> .top {
padding: 16px 16px 0 16px;
}
> .bottom {
padding: 0 16px 16px 16px;
position: relative;
> .typers {
position: absolute;
bottom: 100%;
padding: 0 8px 0 8px;
font-size: 0.9em;
background: var(--panel);
border-radius: 0 8px 0 0;
color: var(--fgTransparentWeak);
> .users {
> .user + .user:before {
content: ", ";
font-weight: normal;
}
> .user:last-of-type:after {
content: " ";
}
}
}
}
> .tl {
position: relative;
padding: 16px 0;
flex: 1;
min-width: 0;
overflow: auto;
> .new {
position: fixed;
z-index: 1000;
pointer-events: none;
> button {
display: block;
margin: 16px auto;
padding: 8px 16px;
border-radius: 32px;
}
}
}
}
</style>

View File

@ -14,7 +14,7 @@
</I18n>
<MkEllipsis/>
</div>
<XPostForm :channel="channel"/>
<XPostForm :channel="channel" :defaultFocus="channelId != null" />
</div>
<div ref="scroller" class="tl">
<div v-if="queue > 0" class="new" :style="{ width: width + 'px', top: top + 'px' }"><button class="_buttonPrimary" @click="goTop()">{{ i18n.ts.newNoteRecived }}</button></div>
@ -187,6 +187,7 @@ function queueUpdated(q) {
flex-direction: column;
flex: 1;
overflow: auto;
background: var(--panel);
> .info {
padding: 16px 16px 0 16px;

View File

@ -85,7 +85,7 @@ export default defineComponent({
required: false
},
channel: {
type: String,
type: Object,
required: false
},
mention: {
@ -109,7 +109,7 @@ export default defineComponent({
required: false,
default: false
},
autofocus: {
defaultFocus: {
type: Boolean,
required: false,
default: false
@ -268,12 +268,8 @@ export default defineComponent({
this.cw = this.reply.cw;
}
if (this.autofocus) {
if (this.defaultFocus) {
this.focus();
this.$nextTick(() => {
this.focus();
});
}
// TODO: detach when unmount
@ -428,7 +424,7 @@ export default defineComponent({
},
onKeydown(e: KeyboardEvent) {
if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post();
if ((e.which === 10 || e.which === 13) && (e.ctrlKey || e.metaKey) && this.canPost) this.post(true);
if (e.which === 27) this.$emit('esc');
this.typing();
},
@ -538,7 +534,7 @@ export default defineComponent({
localStorage.setItem('drafts', JSON.stringify(data));
},
async post() {
async post(keepFocus) {
let data = {
text: this.text == '' ? undefined : this.text,
fileIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined,
@ -560,7 +556,8 @@ export default defineComponent({
}
this.posting = true;
os.api('notes/create', data).then(() => {
try {
await os.api('notes/create', data);
this.clear();
this.$nextTick(() => {
this.deleteDraft();
@ -571,14 +568,17 @@ export default defineComponent({
localStorage.setItem('hashtags', JSON.stringify(unique(hashtags.concat(history))));
}
this.posting = false;
if (keepFocus) {
this.$nextTick(this.focus)
}
});
}).catch(err => {
} catch {
this.posting = false;
os.alert({
type: 'error',
text: err.message + '\n' + (err as any).id,
});
});
}
},
cancel() {

View File

@ -7,7 +7,7 @@
<transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="tags">
<div v-for="stat in stats" :key="stat.tag">
<div class="tag">
<MkA class="a" :to="`/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</MkA>
<MkA class="a" :to="`/explore/tags/${ encodeURIComponent(stat.tag) }`" :title="stat.tag">#{{ stat.tag }}</MkA>
<p>{{ $t('nUsersMentioned', { n: stat.usersCount }) }}</p>
</div>
<MkMiniChart class="chart" :src="stat.chart"/>

File diff suppressed because it is too large Load Diff

1523
yarn.lock

File diff suppressed because it is too large Load Diff