mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 14:04:21 +01:00
feat: support video
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { Component, createSignal, Show } from 'solid-js';
|
import { Component, createSignal, Show } from 'solid-js';
|
||||||
|
|
||||||
import SafeLink from '@/components/utils/SafeLink';
|
import SafeLink from '@/components/utils/SafeLink';
|
||||||
import { fixUrl } from '@/utils/imageUrl';
|
import { fixUrl } from '@/utils/url';
|
||||||
|
|
||||||
type ImageDisplayProps = {
|
type ImageDisplayProps = {
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import EventDisplayById from '@/components/event/EventDisplayById';
|
|||||||
import ImageDisplay from '@/components/event/textNote/ImageDisplay';
|
import ImageDisplay from '@/components/event/textNote/ImageDisplay';
|
||||||
import MentionedEventDisplay from '@/components/event/textNote/MentionedEventDisplay';
|
import MentionedEventDisplay from '@/components/event/textNote/MentionedEventDisplay';
|
||||||
import MentionedUserDisplay from '@/components/event/textNote/MentionedUserDisplay';
|
import MentionedUserDisplay from '@/components/event/textNote/MentionedUserDisplay';
|
||||||
|
import VideoDisplay from '@/components/event/textNote/VideoDisplay';
|
||||||
import EventLink from '@/components/EventLink';
|
import EventLink from '@/components/EventLink';
|
||||||
import SafeLink from '@/components/utils/SafeLink';
|
import SafeLink from '@/components/utils/SafeLink';
|
||||||
import { createSearchColumn } from '@/core/column';
|
import { createSearchColumn } from '@/core/column';
|
||||||
@@ -15,7 +16,7 @@ import useConfig from '@/core/useConfig';
|
|||||||
import { useRequestCommand } from '@/hooks/useCommandBus';
|
import { useRequestCommand } from '@/hooks/useCommandBus';
|
||||||
import { textNote } from '@/nostr/event';
|
import { textNote } from '@/nostr/event';
|
||||||
import { type ParsedTextNoteNode } from '@/nostr/parseTextNote';
|
import { type ParsedTextNoteNode } from '@/nostr/parseTextNote';
|
||||||
import { isImageUrl } from '@/utils/imageUrl';
|
import { isImageUrl, isVideoUrl } from '@/utils/url';
|
||||||
|
|
||||||
export type TextNoteContentDisplayProps = {
|
export type TextNoteContentDisplayProps = {
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
@@ -41,15 +42,14 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
|||||||
return <span>{item.content}</span>;
|
return <span>{item.content}</span>;
|
||||||
}
|
}
|
||||||
if (item.type === 'URL') {
|
if (item.type === 'URL') {
|
||||||
|
const initialHidden = () =>
|
||||||
|
!config().showMedia || event().contentWarning().contentWarning || !props.embedding;
|
||||||
|
|
||||||
if (isImageUrl(item.content)) {
|
if (isImageUrl(item.content)) {
|
||||||
return (
|
return <ImageDisplay url={item.content} initialHidden={initialHidden()} />;
|
||||||
<ImageDisplay
|
}
|
||||||
url={item.content}
|
if (isVideoUrl(item.content)) {
|
||||||
initialHidden={
|
return <VideoDisplay url={item.content} initialHidden={initialHidden()} />;
|
||||||
!config().showImage || event().contentWarning().contentWarning || !props.embedding
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return <SafeLink class="text-blue-500 underline" href={item.content} />;
|
return <SafeLink class="text-blue-500 underline" href={item.content} />;
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/components/event/textNote/VideoDisplay.tsx
Normal file
41
src/components/event/textNote/VideoDisplay.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Component, createSignal, Show } from 'solid-js';
|
||||||
|
|
||||||
|
import SafeLink from '@/components/utils/SafeLink';
|
||||||
|
|
||||||
|
type VideoDisplayProps = {
|
||||||
|
url: string;
|
||||||
|
initialHidden: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VideoDisplay: Component<VideoDisplayProps> = (props) => {
|
||||||
|
let videoRef: HTMLVideoElement | undefined;
|
||||||
|
const [hidden, setHidden] = createSignal(props.initialHidden);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={!hidden()}
|
||||||
|
fallback={
|
||||||
|
<button
|
||||||
|
class="rounded bg-stone-300 p-3 text-xs text-stone-600 hover:shadow"
|
||||||
|
onClick={() => setHidden(false)}
|
||||||
|
>
|
||||||
|
動画を表示する
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SafeLink class="my-2 block" href={props.url}>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
class="max-h-64 max-w-full rounded-sm object-contain shadow"
|
||||||
|
src={props.url}
|
||||||
|
controls
|
||||||
|
>
|
||||||
|
<a href={props.url}>ダウンロード</a>
|
||||||
|
</video>
|
||||||
|
</SafeLink>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VideoDisplay;
|
||||||
@@ -451,10 +451,10 @@ const OtherConfig = () => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleShowImage = () => {
|
const toggleShowMedia = () => {
|
||||||
setConfig((current) => ({
|
setConfig((current) => ({
|
||||||
...current,
|
...current,
|
||||||
showImage: !(current.showImage ?? true),
|
showMedia: !(current.showMedia ?? true),
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -477,8 +477,8 @@ const OtherConfig = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full">
|
<div class="flex w-full">
|
||||||
<div class="flex-1">{i18n()('config.display.showImagesByDefault')}</div>
|
<div class="flex-1">{i18n()('config.display.showMediaByDefault')}</div>
|
||||||
<ToggleButton value={config().showImage} onClick={() => toggleShowImage()} />
|
<ToggleButton value={config().showMedia} onClick={() => toggleShowMedia()} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full">
|
<div class="flex w-full">
|
||||||
<div class="flex-1">{i18n()('config.display.hideNumbers')}</div>
|
<div class="flex-1">{i18n()('config.display.hideNumbers')}</div>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export type Config = {
|
|||||||
keepOpenPostForm: boolean;
|
keepOpenPostForm: boolean;
|
||||||
useEmojiReaction: boolean;
|
useEmojiReaction: boolean;
|
||||||
showEmojiReaction: boolean;
|
showEmojiReaction: boolean;
|
||||||
showImage: boolean;
|
showMedia: boolean; // TODO 'always' | 'only-followings' | 'never'
|
||||||
hideCount: boolean;
|
hideCount: boolean;
|
||||||
mutedPubkeys: string[];
|
mutedPubkeys: string[];
|
||||||
mutedKeywords: string[];
|
mutedKeywords: string[];
|
||||||
@@ -80,7 +80,7 @@ const InitialConfig = (): Config => ({
|
|||||||
keepOpenPostForm: false,
|
keepOpenPostForm: false,
|
||||||
useEmojiReaction: false,
|
useEmojiReaction: false,
|
||||||
showEmojiReaction: false,
|
showEmojiReaction: false,
|
||||||
showImage: true,
|
showMedia: true,
|
||||||
hideCount: false,
|
hideCount: false,
|
||||||
mutedPubkeys: [],
|
mutedPubkeys: [],
|
||||||
mutedKeywords: [],
|
mutedKeywords: [],
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export default {
|
|||||||
showEmojiReaction: 'Show emoji reactions on posts',
|
showEmojiReaction: 'Show emoji reactions on posts',
|
||||||
others: 'Others',
|
others: 'Others',
|
||||||
keepOpenPostForm: 'Remain the input field open after posting',
|
keepOpenPostForm: 'Remain the input field open after posting',
|
||||||
showImagesByDefault: 'Load images by default',
|
showMediaByDefault: 'Load media by default',
|
||||||
hideNumbers: 'Hide the numbers of reactions, reposts and followers',
|
hideNumbers: 'Hide the numbers of reactions, reposts and followers',
|
||||||
},
|
},
|
||||||
customEmoji: {
|
customEmoji: {
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default {
|
|||||||
showEmojiReaction: '投稿にリアクションされた絵文字を表示する',
|
showEmojiReaction: '投稿にリアクションされた絵文字を表示する',
|
||||||
others: 'その他',
|
others: 'その他',
|
||||||
keepOpenPostForm: '投稿後も投稿欄を開いたままにする',
|
keepOpenPostForm: '投稿後も投稿欄を開いたままにする',
|
||||||
showImagesByDefault: 'デフォルトで画像を読み込む',
|
showMediaByDefault: 'デフォルトで画像や動画を読み込む',
|
||||||
hideNumbers: 'いいねやリポスト、フォロワーなどの数を隠す',
|
hideNumbers: 'いいねやリポスト、フォロワーなどの数を隠す',
|
||||||
},
|
},
|
||||||
customEmoji: {
|
customEmoji: {
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { isImageUrl } from '@/utils/imageUrl';
|
|
||||||
|
|
||||||
const ChannelMetaSchema = z.object({
|
const ChannelMetaSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
about: z.string().optional(),
|
about: z.string().optional(),
|
||||||
picture: z
|
picture: z.string().url().optional(),
|
||||||
.string()
|
|
||||||
.url()
|
|
||||||
.refine((url) => isImageUrl(url), { message: 'not an image url' })
|
|
||||||
.optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ChannelMeta = z.infer<typeof ChannelMetaSchema>;
|
export type ChannelMeta = z.infer<typeof ChannelMetaSchema>;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from 'assert';
|
|||||||
|
|
||||||
import { describe, it } from 'vitest';
|
import { describe, it } from 'vitest';
|
||||||
|
|
||||||
import { fixUrl } from '@/utils/imageUrl';
|
import { fixUrl } from '@/utils/url';
|
||||||
|
|
||||||
describe('fixUrl', () => {
|
describe('fixUrl', () => {
|
||||||
it('should return an image url for a given imgur.com URL with additional path', () => {
|
it('should return an image url for a given imgur.com URL with additional path', () => {
|
||||||
@@ -7,6 +7,15 @@ export const isImageUrl = (urlString: string): boolean => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isVideoUrl = (urlString: string): boolean => {
|
||||||
|
try {
|
||||||
|
const url = new URL(urlString);
|
||||||
|
return /\.(mpg|mpeg|mp4|avi|mov|webm|ogv)$/i.test(url.pathname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const fixUrl = (urlString: string): string => {
|
export const fixUrl = (urlString: string): string => {
|
||||||
try {
|
try {
|
||||||
const url = new URL(urlString);
|
const url = new URL(urlString);
|
||||||
Reference in New Issue
Block a user