Merge remote-branch 'misskey/develop'

This commit is contained in:
NoriDev 2023-11-30 19:58:23 +09:00
commit feef0d6ffd
39 changed files with 388 additions and 84 deletions

View file

@ -22,16 +22,13 @@ jobs:
api-json-name: [api-base.json, api-head.json]
include:
- api-json-name: api-base.json
repo-name: ${{ github.event.pull_request.base.repo.full_name }}
ref: ${{ github.base_ref }}
- api-json-name: api-head.json
repo-name: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.head_ref }}
ref: refs/pull/${{ github.event.number }}/merge
steps:
- uses: actions/checkout@v4.1.1
with:
repository: ${{ matrix.repo-name }}
ref: ${{ matrix.ref }}
submodules: true
- name: Install pnpm

View file

@ -21,15 +21,22 @@
- Fix: MFM `$[unixtime ]` に不正な値を入力した際に発生する各種エラーを修正
### Client
- Feat: 今日誕生日のフォロー中のユーザーを一覧表示できるウィジェットを追加
- Enhance: 絵文字のオートコンプリート機能強化 #12364
- Enhance: ユーザーのRawデータを表示するページが復活
- Enhance: リアクション選択時に音を鳴らせるように
- Enhance: サウンドにドライブのファイルを使用できるように
- Enhance: ナビゲーションバーに項目「キャッシュを削除」を追加
- Enhance: Shareページで投稿を完了すると、親ウィンドウ親フレームにpostMessageするように
- Enhance: チャンネル、クリップ、ページ、Play、ギャラリーにURLのコピーボタンを設置 #11305
- Enhance: ノートプレビューに「内容を隠す」が反映されるように
- fix: 「設定のバックアップ」で一部の項目がバックアップに含まれていなかった問題を修正
- Fix: ウィジェットのジョブキューにて音声の発音方法変更に追従できていなかったのを修正 #12367
- Enhance: 絵文字の詳細ページに記載される情報を追加
- Fix: コードエディタが正しく表示されない問題を修正
- Fix: プロフィールの「ファイル」にセンシティブな画像がある際のデザインを修正
- Fix: 一度に大量の通知が入った際に通知音が音割れする問題を修正
- Fix: 共有機能をサポートしていないブラウザの場合は共有ボタンを非表示にする #11305
- Fix: 通知のグルーピング設定を変更してもリロードされるまで表示が変わらない問題を修正 #12470
### Server

View file

@ -67,8 +67,8 @@ RUN apt-get update \
&& corepack enable \
&& groupadd -g "${GID}" cherrypick \
&& useradd -l -u "${UID}" -g "${GID}" -m -d /cherrypick cherrypick \
&& find / -type d -path /proc -prune -o -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \
&& find / -type d -path /proc -prune -o -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; \
&& find / -type d -path /sys -prune -o -type d -path /proc -prune -o -type f -perm /u+s -ignore_readdir_race -exec chmod u-s {} \; \
&& find / -type d -path /sys -prune -o -type d -path /proc -prune -o -type f -perm /g+s -ignore_readdir_race -exec chmod g-s {} \; \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists

3
locales/index.d.ts vendored
View file

@ -1130,6 +1130,8 @@ export interface Locale {
"sensitiveWords": string;
"sensitiveWordsDescription": string;
"sensitiveWordsDescription2": string;
"hiddenTags": string;
"hiddenTagsDescription": string;
"notesSearchNotAvailable": string;
"license": string;
"unfavoriteConfirm": string;
@ -2423,6 +2425,7 @@ export interface Locale {
"chooseList": string;
};
"clicker": string;
"birthdayFollowings": string;
};
"_cw": {
"hide": string;

View file

@ -1127,6 +1127,8 @@ resetPasswordConfirm: "パスワードリセットしますか?"
sensitiveWords: "センシティブワード"
sensitiveWordsDescription: "設定したワードが含まれるノートの公開範囲をホームにします。改行で区切って複数設定できます。"
sensitiveWordsDescription2: "スペースで区切るとAND指定になり、キーワードをスラッシュで囲むと正規表現になります。"
hiddenTags: "非表示ハッシュタグ"
hiddenTagsDescription: "設定したタグをトレンドに表示させないようにします。改行で区切って複数設定できます。"
notesSearchNotAvailable: "ノート検索は利用できません。"
license: "ライセンス"
unfavoriteConfirm: "お気に入り解除しますか?"
@ -2324,6 +2326,7 @@ _widgets:
_userList:
chooseList: "リストを選択"
clicker: "クリッカー"
birthdayFollowings: "今日誕生日のユーザー"
_cw:
hide: "隠す"

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddBdayIndex1700902349231 {
name = 'AddBdayIndex1700902349231'
async up(queryRunner) {
await queryRunner.query(`CREATE INDEX "IDX_de22cd2b445eee31ae51cdbe99" ON "user_profile" (SUBSTR("birthday", 6, 5))`);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_de22cd2b445eee31ae51cdbe99"`);
}
}

View file

@ -29,6 +29,7 @@ export class MiUserProfile {
})
public location: string | null;
@Index()
@Column('char', {
length: 10, nullable: true,
comment: 'The birthday (YYYY-MM-DD) of the User.',

View file

@ -33,13 +33,7 @@ export const meta = {
items: {
type: 'object',
optional: false, nullable: false,
properties: {
code: {
type: 'string',
optional: false, nullable: false,
example: 'GR6S02ERUA5VR',
},
},
ref: 'InviteCode',
},
},
} as const;

View file

@ -21,6 +21,7 @@ export const meta = {
items: {
type: 'object',
optional: false, nullable: false,
ref: 'InviteCode',
},
},
} as const;

View file

@ -31,13 +31,7 @@ export const meta = {
res: {
type: 'object',
optional: false, nullable: false,
properties: {
code: {
type: 'string',
optional: false, nullable: false,
example: 'GR6S02ERUA5VR',
},
},
ref: 'InviteCode',
},
} as const;

View file

@ -9,7 +9,6 @@ import type { RegistrationTicketsRepository } from '@/models/_.js';
import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['meta'],
@ -23,6 +22,7 @@ export const meta = {
items: {
type: 'object',
optional: false, nullable: false,
ref: 'InviteCode',
},
},
} as const;

View file

@ -42,6 +42,12 @@ export const meta = {
code: 'FORBIDDEN',
id: 'f6cdb0df-c19f-ec5c-7dbb-0ba84a1f92ba',
},
birthdayInvalid: {
message: 'Birthday date format is invalid.',
code: 'BIRTHDAY_DATE_FORMAT_INVALID',
id: 'a2b007b9-4782-4eba-abd3-93b05ed4130d',
},
},
} as const;
@ -59,6 +65,8 @@ export const paramDef = {
nullable: true,
description: 'The local host is represented with `null`.',
},
birthday: { type: 'string', nullable: true },
},
anyOf: [
{ required: ['userId'] },
@ -117,6 +125,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.andWhere('following.followerId = :userId', { userId: user.id })
.innerJoinAndSelect('following.followee', 'followee');
if (ps.birthday) {
try {
const d = new Date(ps.birthday);
d.setHours(0, 0, 0, 0);
const birthday = `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
const birthdayUserQuery = this.userProfilesRepository.createQueryBuilder('user_profile');
birthdayUserQuery.select('user_profile.userId')
.where(`SUBSTR(user_profile.birthday, 6, 5) = '${birthday}'`);
query.andWhere(`following.followeeId IN (${ birthdayUserQuery.getQuery() })`);
} catch (err) {
throw new ApiError(meta.errors.birthdayInvalid);
}
}
const followings = await query
.limit(ps.limit)
.getMany();

View file

@ -16,7 +16,22 @@ import MkButton from '@/components/MkButton.vue';
const props = defineProps<{
modelValue: boolean;
note: Misskey.entities.Note;
text: string | null;
files: Misskey.entities.DriveFile[];
poll?: {
expiresAt: string | null;
multiple: boolean;
choices: {
isVoted: boolean;
text: string;
votes: number;
}[];
} | {
choices: string[];
multiple: boolean;
expiresAt: string | null;
expiredAfter: string | null;
};
}>();
const emit = defineEmits<{
@ -25,9 +40,9 @@ const emit = defineEmits<{
const label = computed(() => {
return concat([
props.note.text ? [i18n.t('_cw.chars', { count: props.note.text.length })] : [],
props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length })] : [],
props.note.poll != null ? [i18n.ts.poll] : [],
props.text ? [i18n.t('_cw.chars', { count: props.text.length })] : [],
props.files.length !== 0 ? [i18n.t('_cw.files', { count: props.files.length })] : [],
props.poll != null ? [i18n.ts.poll] : [],
] as string[][]).join(' / ');
});

View file

@ -69,7 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkEvent v-if="appearNote.event" :note="appearNote"/>
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" :text="appearNote.cw" :author="appearNote.user" :nyaize="noNyaize ? false : 'respect'" @click.stop/>
<MkCwButton v-model="showContent" :note="appearNote" style="margin: 4px 0;" @click.stop/>
<MkCwButton v-model="showContent" :text="appearNote.text" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;" @click.stop/>
</p>
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
<div :class="$style.text">

View file

@ -89,7 +89,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkEvent v-if="appearNote.event" :note="appearNote"/>
<p v-if="appearNote.cw != null" :class="$style.cw">
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="noNyaize ? false : 'respect'"/>
<MkCwButton v-model="showContent" :note="appearNote"/>
<MkCwButton v-model="showContent" :text="appearNote.text" :files="appearNote.files" :poll="appearNote.poll"/>
</p>
<div v-show="appearNote.cw == null || showContent">
<span v-if="appearNote.isHidden" style="opacity: 0.5">({{ i18n.ts._ffVisibility.private }})</span>

View file

@ -11,7 +11,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkUserName :user="user" :nowrap="true"/>
</div>
<div>
<div>
<p v-if="useCw" :class="$style.cw">
<Mfm v-if="cw != ''" :text="cw" :author="user" :nyaize="'respect'" :i="user" style="margin-right: 8px;"/>
<MkCwButton v-model="showContent" :text="text.trim()" :files="files" :poll="poll" style="margin: 4px 0;"/>
</p>
<div v-show="!useCw || showContent">
<Mfm :text="text.trim()" :author="user" :nyaize="'respect'" :i="user"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :class="$style.urlPreview"/>
</div>
@ -21,15 +25,27 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { } from 'vue';
import { ref } from 'vue';
import * as Misskey from 'cherrypick-js';
import * as mfm from 'cherrypick-mfm-js';
import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkCwButton from '@/components/MkCwButton.vue';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
import { defaultStore } from '@/store.js';
const showContent = ref(false);
const props = defineProps<{
text: string;
files: Misskey.entities.DriveFile[];
poll?: {
choices: string[];
multiple: boolean;
expiresAt: string | null;
expiredAfter: string | null;
};
useCw: boolean;
cw: string | null;
user: Misskey.entities.User;
showProfile?: boolean;
}>();
@ -62,6 +78,14 @@ const urls = props.text ? extractUrlFromMfm(mfm.parse(props.text)) : null;
min-width: 0;
}
.cw {
cursor: default;
display: block;
margin: 0;
padding: 0;
overflow-wrap: break-word;
}
.header {
margin-bottom: 2px;
font-weight: bold;

View file

@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkEvent v-if="note.event" :note="note"/>
<p v-if="note.cw != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" :emojiUrls="note.emojis"/>
<MkCwButton v-model="showContent" :note="note"/>
<MkCwButton v-model="showContent" :text="note.text" :files="note.files" :poll="note.poll"/>
</p>
<div v-show="note.cw == null || showContent">
<MkSubNoteContent :class="$style.text" :note="note"/>

View file

@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkEvent v-if="note.event" :note="note"/>
<p v-if="note.cw != null" :class="$style.cw">
<Mfm v-if="note.cw != ''" style="margin-right: 8px;" :text="note.cw" :author="note.user" :nyaize="'respect'" @click.stop/>
<MkCwButton v-model="showContent" style="width: 100%" :note="note" @click.stop/>
<MkCwButton v-model="showContent" style="width: 100%" :text="note.text" :files="note.files" :poll="note.poll" @click.stop/>
</p>
<div v-show="note.cw == null || showContent">
<MkSubNoteContent :class="$style.text" :note="note" :showSubNoteFooterButton="defaultStore.state.showSubNoteFooterButton"/>

View file

@ -75,7 +75,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :user="postAccount ?? $i" :showProfile="showProfilePreview"/>
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i" :showProfile="showProfilePreview"/>
<div v-if="showingOptions" style="padding: 8px 16px;">
</div>
<footer :class="$style.footer">

View file

@ -88,7 +88,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<input v-show="withHashtags && showForm" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
<XPostFormAttaches v-if="showForm" v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
<MkPollEditor v-if="poll && showForm" v-model="poll" @destroyed="poll = null"/>
<MkNotePreview v-if="showPreview && showForm" :class="$style.preview" :text="text" :user="postAccount ?? $i" :showProfile="showProfilePreview"/>
<MkNotePreview v-if="showPreview && showForm" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i" :showProfile="showProfilePreview"/>
<div v-if="showingOptions && showForm" style="padding: 8px 16px;">
</div>
<Transition

View file

@ -70,6 +70,7 @@ import { mainRouter } from '@/router.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';
import { PageHeaderItem } from '@/types/page-header.js';
import MkFollowButton from '@/components/MkFollowButton.vue';
let showFollowButton = $ref(false);
@ -88,12 +89,7 @@ type Tab = {
const props = withDefaults(defineProps<{
tabs?: Tab[];
tab?: string;
actions?: {
text: string;
icon: string;
highlighted?: boolean;
handler: (ev: MouseEvent) => void;
}[];
actions?: PageHeaderItem[];
thin?: boolean;
displayMyAvatar?: boolean;
}>(), {

View file

@ -104,7 +104,7 @@ export default function(props: MfmProps) {
case 'fn': {
// TODO: CSSを文字列で組み立てていくと token.props.args.~~~ 経由でCSSインジェクションできるのでよしなにやる
let style;
let style: string | undefined;
switch (token.props.name) {
case 'tada': {
const speed = validTime(token.props.args.speed) ?? '1s';
@ -277,7 +277,7 @@ export default function(props: MfmProps) {
]);
}
}
if (style == null) {
if (style === undefined) {
return h('span', {}, ['$[', token.props.name, ' ', ...genEl(token.children, scale), ']']);
} else {
return h('span', {

View file

@ -74,6 +74,7 @@ import { mainRouter } from '@/router.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { defaultStore } from '@/store.js';
import { PageHeaderItem } from '@/types/page-header.js';
import MkFollowButton from '@/components/MkFollowButton.vue';
let showFollowButton = $ref(false);
@ -84,12 +85,7 @@ const canBack = ref(['index', 'explore', 'my-notifications', 'messaging'].includ
const props = withDefaults(defineProps<{
tabs?: Tab[];
tab?: string;
actions?: {
text: string;
icon: string;
highlighted?: boolean;
handler: (ev: MouseEvent) => void;
}[];
actions?: PageHeaderItem[];
thin?: boolean;
displayMyAvatar?: boolean;
}>(), {

View file

@ -4,7 +4,6 @@
*/
import { computed, defineAsyncComponent, reactive } from 'vue';
import { clearCache } from './scripts/clear-cache.js';
import { $i } from '@/account.js';
import { miLocalStorage } from '@/local-storage.js';
import { openInstanceMenu, openToolsMenu } from '@/ui/_common_/common.js';
@ -14,6 +13,7 @@ import { i18n } from '@/i18n.js';
import { ui } from '@/config.js';
import { defaultStore } from '@/store.js';
import { unisonReload } from '@/scripts/unison-reload.js';
import { clearCache } from './scripts/clear-cache.js';
export const navbarItemDef = reactive({
notifications: {

View file

@ -39,6 +39,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.sensitiveWords }}</template>
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
</MkTextarea>
<MkTextarea v-model="hiddenTags">
<template #label>{{ i18n.ts.hiddenTags }}</template>
<template #caption>{{ i18n.ts.hiddenTagsDescription }}</template>
</MkTextarea>
</div>
</FormSuspense>
</MkSpacer>
@ -72,6 +77,7 @@ import FormLink from '@/components/form/link.vue';
let enableRegistration: boolean = $ref(false);
let emailRequiredForSignup: boolean = $ref(false);
let sensitiveWords: string = $ref('');
let hiddenTags: string = $ref('');
let preservedUsernames: string = $ref('');
let tosUrl: string | null = $ref(null);
let privacyPolicyUrl: string | null = $ref(null);
@ -81,6 +87,7 @@ async function init() {
enableRegistration = !meta.disableRegistration;
emailRequiredForSignup = meta.emailRequiredForSignup;
sensitiveWords = meta.sensitiveWords.join('\n');
hiddenTags = meta.hiddenTags.join('\n');
preservedUsernames = meta.preservedUsernames.join('\n');
tosUrl = meta.tosUrl;
privacyPolicyUrl = meta.privacyPolicyUrl;
@ -93,6 +100,7 @@ function save() {
tosUrl,
privacyPolicyUrl,
sensitiveWords: sensitiveWords.split('\n'),
hiddenTags: hiddenTags.split('\n'),
preservedUsernames: preservedUsernames.split('\n'),
}).then(() => {
fetchInstance();

View file

@ -86,6 +86,9 @@ import { defaultStore } from '@/store.js';
import MkNote from '@/components/MkNote.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { PageHeaderItem } from '@/types/page-header.js';
import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
const router = useRouter();
@ -167,24 +170,40 @@ async function search() {
const headerActions = $computed(() => {
if (channel && channel.userId) {
const share = {
icon: 'ti ti-share',
text: i18n.ts.share,
handler: async (): Promise<void> => {
navigator.share({
title: channel.name,
text: channel.description,
url: `${url}/channels/${channel.id}`,
});
},
};
const headerItems: PageHeaderItem[] = [];
const canEdit = ($i && $i.id === channel.userId) || iAmModerator;
return canEdit ? [share, {
icon: 'ti ti-settings',
text: i18n.ts.edit,
handler: edit,
}] : [share];
headerItems.push({
icon: 'ti ti-link',
text: i18n.ts.copyUrl,
handler: async (): Promise<void> => {
copyToClipboard(`${url}/channels/${channel.id}`);
os.success();
},
});
if (isSupportShare()) {
headerItems.push({
icon: 'ti ti-share',
text: i18n.ts.share,
handler: async (): Promise<void> => {
navigator.share({
title: channel.name,
text: channel.description,
url: `${url}/channels/${channel.id}`,
});
},
});
}
if (($i && $i.id === channel.userId) || iAmModerator) {
headerItems.push({
icon: 'ti ti-settings',
text: i18n.ts.edit,
handler: edit,
});
}
return headerItems.length > 0 ? headerItems : null;
} else {
return null;
}

View file

@ -36,6 +36,8 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { url } from '@/config.js';
import MkButton from '@/components/MkButton.vue';
import { clipsCache } from '@/cache';
import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
const props = defineProps<{
clipId: string,
@ -118,6 +120,13 @@ const headerActions = $computed(() => clip && isOwned ? [{
clipsCache.delete();
},
}, ...(clip.isPublic ? [{
icon: 'ti ti-link',
text: i18n.ts.copyUrl,
handler: async (): Promise<void> => {
copyToClipboard(`${url}/clips/${clip.id}`);
os.success();
},
}] : []), ...(clip.isPublic && isSupportShare() ? [{
icon: 'ti ti-share',
text: i18n.ts.share,
handler: async (): Promise<void> => {

View file

@ -46,7 +46,7 @@ function menu(ev) {
os.apiGet('emoji', { name: props.emoji.name }).then(res => {
os.alert({
type: 'info',
text: `License: ${res.license}`,
text: `Name: ${res.name}\nAliases: ${res.aliases.join(' ')}\nCategory: ${res.category}\nisSensitive: ${res.isSensitive}\nlocalOnly: ${res.localOnly}\nLicense: ${res.license}\nURL: ${res.url}`,
});
});
},

View file

@ -18,7 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" asLike class="button" rounded primary @click="unlike()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
<MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton>
<MkButton v-tooltip="i18n.ts.shareWithNote" class="button" rounded @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></MkButton>
<MkButton v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton>
<MkButton v-tooltip="i18n.ts.copyLink" class="button" rounded @click="copyLink"><i class="ti ti-link ti-fw"></i></MkButton>
<MkButton v-if="isSupportShare()" v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton>
</div>
</div>
<div v-else :class="$style.ready">
@ -70,6 +71,8 @@ import MkFolder from '@/components/MkFolder.vue';
import MkCode from '@/components/MkCode.vue';
import { defaultStore } from '@/store.js';
import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
const props = defineProps<{
id: string;
@ -89,6 +92,11 @@ function fetchFlash() {
});
}
function copyLink() {
copyToClipboard(`${url}/play/${flash.id}`);
os.success();
}
function share() {
navigator.share({
title: flash.title,

View file

@ -29,7 +29,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="other">
<button v-if="$i && $i.id === post.user.id" v-tooltip="i18n.ts.edit" v-click-anime class="_button" @click="edit"><i class="ti ti-pencil ti-fw"></i></button>
<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
<button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
<button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ti ti-link ti-fw"></i></button>
<button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
</div>
</div>
<div class="user">
@ -74,6 +75,8 @@ import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { defaultStore } from '@/store.js';
import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
const router = useRouter();
@ -102,6 +105,11 @@ function fetchPost() {
});
}
function copyLink() {
copyToClipboard(`${url}/gallery/${post.id}`);
os.success();
}
function share() {
navigator.share({
title: post.title,

View file

@ -34,7 +34,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div class="other">
<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
<button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
<button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ti ti-link ti-fw"></i></button>
<button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button>
</div>
</div>
<div class="user">
@ -90,6 +91,8 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { pageViewInterruptors, defaultStore } from '@/store.js';
import { deepClone } from '@/scripts/clone.js';
import { $i } from '@/account.js';
import { isSupportShare } from '@/scripts/navigator.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
const props = defineProps<{
pageName: string;
@ -136,6 +139,11 @@ function share() {
});
}
function copyLink() {
copyToClipboard(`${url}/@${page.user.username}/pages/${page.name}`);
os.success();
}
function shareWithNote() {
os.post({
initialText: `${page.title || page.name} ${url}/@${page.user.username}/pages/${page.name}`,

View file

@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:renote="renote"
:initialVisibleUsers="visibleUsers"
class="_panel"
@posted="state = 'posted'"
@posted="onPosted"
/>
<div v-else-if="state === 'posted'" class="_buttonsCenter">
<MkButton primary @click="close">{{ i18n.ts.close }}</MkButton>
@ -32,20 +32,20 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
// SPECIFICATION: https://misskey-hub.net/docs/features/share-form.html
import { } from 'vue';
import { ref } from 'vue';
import * as Misskey from 'cherrypick-js';
import MkButton from '@/components/MkButton.vue';
import MkPostForm from '@/components/MkPostForm.vue';
import * as os from '@/os.js';
import { mainRouter } from '@/router.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { postMessageToParentWindow } from '@/scripts/post-message.js';
import { i18n } from '@/i18n.js';
const urlParams = new URLSearchParams(window.location.search);
const localOnlyQuery = urlParams.get('localOnly');
const visibilityQuery = urlParams.get('visibility') as typeof Misskey.noteVisibilities[number];
let state = $ref('fetching' as 'fetching' | 'writing' | 'posted');
const state = ref<'fetching' | 'writing' | 'posted'>('fetching');
let title = $ref(urlParams.get('title'));
const text = urlParams.get('text');
const url = urlParams.get('url');
@ -144,7 +144,7 @@ async function init() {
});
}
state = 'writing';
state.value = 'writing';
}
init();
@ -162,6 +162,11 @@ function goToMisskey(): void {
location.href = '/';
}
function onPosted(): void {
state.value = 'posted';
postMessageToParentWindow('misskey:shareForm:shareCompleted');
}
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);

View file

@ -18,6 +18,7 @@ import { getUserMenu } from '@/scripts/get-user-menu.js';
import { clipsCache } from '@/cache.js';
import { MenuItem } from '@/types/menu.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { isSupportShare } from '@/scripts/navigator.js';
export async function getNoteClipMenu(props: {
note: Misskey.entities.Note;
@ -339,11 +340,12 @@ export function getNoteMenu(props: {
danger: true,
action: unclip,
}, null] : []
), {
), ...(isSupportShare() ? [{
icon: 'ti ti-share',
text: i18n.ts.share,
action: share,
}, getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink)
}] : []),
getCopyNoteLinkMenu(appearNote, i18n.ts.copyLink)
, {
icon: 'ti ti-copy',
text: i18n.ts.copyContent,

View file

@ -0,0 +1,8 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function isSupportShare(): boolean {
return 'share' in navigator;
}

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const postMessageEventTypes = [
'misskey:shareForm:shareCompleted',
] as const;
export type PostMessageEventType = typeof postMessageEventTypes[number];
export type MiPostMessageEvent = {
type: PostMessageEventType;
payload?: any;
};
/**
*
*/
export function postMessageToParentWindow(type: PostMessageEventType, payload?: any): void {
window.postMessage({
type,
payload,
}, '*');
}

View file

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export type PageHeaderItem = {
text: string;
icon: string;
highlighted?: boolean;
handler: (ev: MouseEvent) => void;
};

View file

@ -0,0 +1,127 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey, cherrypick contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkContainer :showHeader="widgetProps.showHeader" class="mkw-bdayfollowings">
<template #icon><i class="ti ti-cake"></i></template>
<template #header>{{ i18n.ts._widgets.birthdayFollowings }}</template>
<div :class="$style.bdayFRoot">
<MkLoading v-if="fetching"/>
<div v-else-if="users.length > 0" :class="$style.bdayFGrid">
<MkAvatar v-for="user in users" :key="user.id" :user="user.followee" link preview></MkAvatar>
</div>
<div v-else :class="$style.bdayFFallback">
<img :src="infoImageUrl" class="_ghost" :class="$style.bdayFFallbackImage"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</div>
</MkContainer>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'cherrypick-js';
import { useWidgetPropsManager, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import { GetFormResultType } from '@/scripts/form.js';
import MkContainer from '@/components/MkContainer.vue';
import * as os from '@/os.js';
import { useInterval } from '@/scripts/use-interval.js';
import { i18n } from '@/i18n.js';
import { infoImageUrl } from '@/instance.js';
import { $i } from '@/account.js';
const name = i18n.ts._widgets.birthdayFollowings;
const widgetPropsDef = {
showHeader: {
type: 'boolean' as const,
default: true,
},
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
const props = defineProps<WidgetComponentProps<WidgetProps>>();
const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure } = useWidgetPropsManager(name,
widgetPropsDef,
props,
emit,
);
const users = ref<Misskey.entities.FollowingFolloweePopulated[]>([]);
const fetching = ref(true);
let lastFetchedAt = '1970-01-01';
const fetch = () => {
if (!$i) {
users.value = [];
fetching.value = false;
return;
}
const lfAtD = new Date(lastFetchedAt);
lfAtD.setHours(0, 0, 0, 0);
const now = new Date();
now.setHours(0, 0, 0, 0);
if (now > lfAtD) {
os.api('users/following', {
limit: 18,
birthday: now.toISOString(),
userId: $i.id,
}).then(res => {
users.value = res;
fetching.value = false;
});
lastFetchedAt = now.toISOString();
}
};
useInterval(fetch, 1000 * 60, {
immediate: true,
afterMounted: true,
});
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>
<style lang="scss" module>
.bdayFRoot {
overflow: hidden;
min-height: calc(calc(calc(50px * 3) - 8px) + calc(var(--margin) * 2));
}
.bdayFGrid {
display: grid;
grid-template-columns: repeat(6, 42px);
grid-template-rows: repeat(3, 42px);
place-content: center;
gap: 8px;
margin: var(--margin) auto;
}
.bdayFFallback {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.bdayFFallbackImage {
height: 96px;
width: auto;
max-width: 90%;
margin-bottom: 8px;
border-radius: var(--radius);
}
</style>

View file

@ -33,6 +33,7 @@ export default function(app: App) {
app.component('WidgetAichan', defineAsyncComponent(() => import('./WidgetAichan.vue')));
app.component('WidgetUserList', defineAsyncComponent(() => import('./WidgetUserList.vue')));
app.component('WidgetClicker', defineAsyncComponent(() => import('./WidgetClicker.vue')));
app.component('WidgetBirthdayFollowings', defineAsyncComponent(() => import('./WidgetBirthdayFollowings.vue')));
}
export const widgets = [
@ -63,4 +64,5 @@ export const widgets = [
'aichan',
'userList',
'clicker',
'birthdayFollowings',
];

View file

@ -91,16 +91,10 @@ async function build() {
await build();
if (process.argv.includes("--watch")) {
const watcher = fs.watch('./', { recursive: true });
const watcher = fs.watch('./locales');
for await (const event of watcher) {
const filename = event.filename?.replaceAll('\\', '/');
if (/^packages\/[a-z]+\/src/.test(filename)) {
await build();
}
if (/^locales\/[a-z]+-[A-Z]+\.yml/.test(filename)) {
if (/^[a-z]+-[A-Z]+\.yml/.test(filename)) {
locales = buildLocales();
await copyFrontendLocales()
}