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 SafeLink from '@/components/utils/SafeLink';
|
||||
import { fixUrl } from '@/utils/imageUrl';
|
||||
import { fixUrl } from '@/utils/url';
|
||||
|
||||
type ImageDisplayProps = {
|
||||
url: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import EventDisplayById from '@/components/event/EventDisplayById';
|
||||
import ImageDisplay from '@/components/event/textNote/ImageDisplay';
|
||||
import MentionedEventDisplay from '@/components/event/textNote/MentionedEventDisplay';
|
||||
import MentionedUserDisplay from '@/components/event/textNote/MentionedUserDisplay';
|
||||
import VideoDisplay from '@/components/event/textNote/VideoDisplay';
|
||||
import EventLink from '@/components/EventLink';
|
||||
import SafeLink from '@/components/utils/SafeLink';
|
||||
import { createSearchColumn } from '@/core/column';
|
||||
@@ -15,7 +16,7 @@ import useConfig from '@/core/useConfig';
|
||||
import { useRequestCommand } from '@/hooks/useCommandBus';
|
||||
import { textNote } from '@/nostr/event';
|
||||
import { type ParsedTextNoteNode } from '@/nostr/parseTextNote';
|
||||
import { isImageUrl } from '@/utils/imageUrl';
|
||||
import { isImageUrl, isVideoUrl } from '@/utils/url';
|
||||
|
||||
export type TextNoteContentDisplayProps = {
|
||||
event: NostrEvent;
|
||||
@@ -41,15 +42,14 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
||||
return <span>{item.content}</span>;
|
||||
}
|
||||
if (item.type === 'URL') {
|
||||
const initialHidden = () =>
|
||||
!config().showMedia || event().contentWarning().contentWarning || !props.embedding;
|
||||
|
||||
if (isImageUrl(item.content)) {
|
||||
return (
|
||||
<ImageDisplay
|
||||
url={item.content}
|
||||
initialHidden={
|
||||
!config().showImage || event().contentWarning().contentWarning || !props.embedding
|
||||
}
|
||||
/>
|
||||
);
|
||||
return <ImageDisplay url={item.content} initialHidden={initialHidden()} />;
|
||||
}
|
||||
if (isVideoUrl(item.content)) {
|
||||
return <VideoDisplay url={item.content} initialHidden={initialHidden()} />;
|
||||
}
|
||||
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) => ({
|
||||
...current,
|
||||
showImage: !(current.showImage ?? true),
|
||||
showMedia: !(current.showMedia ?? true),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -477,8 +477,8 @@ const OtherConfig = () => {
|
||||
/>
|
||||
</div>
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1">{i18n()('config.display.showImagesByDefault')}</div>
|
||||
<ToggleButton value={config().showImage} onClick={() => toggleShowImage()} />
|
||||
<div class="flex-1">{i18n()('config.display.showMediaByDefault')}</div>
|
||||
<ToggleButton value={config().showMedia} onClick={() => toggleShowMedia()} />
|
||||
</div>
|
||||
<div class="flex w-full">
|
||||
<div class="flex-1">{i18n()('config.display.hideNumbers')}</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export type Config = {
|
||||
keepOpenPostForm: boolean;
|
||||
useEmojiReaction: boolean;
|
||||
showEmojiReaction: boolean;
|
||||
showImage: boolean;
|
||||
showMedia: boolean; // TODO 'always' | 'only-followings' | 'never'
|
||||
hideCount: boolean;
|
||||
mutedPubkeys: string[];
|
||||
mutedKeywords: string[];
|
||||
@@ -80,7 +80,7 @@ const InitialConfig = (): Config => ({
|
||||
keepOpenPostForm: false,
|
||||
useEmojiReaction: false,
|
||||
showEmojiReaction: false,
|
||||
showImage: true,
|
||||
showMedia: true,
|
||||
hideCount: false,
|
||||
mutedPubkeys: [],
|
||||
mutedKeywords: [],
|
||||
|
||||
@@ -98,7 +98,7 @@ export default {
|
||||
showEmojiReaction: 'Show emoji reactions on posts',
|
||||
others: 'Others',
|
||||
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',
|
||||
},
|
||||
customEmoji: {
|
||||
|
||||
@@ -96,7 +96,7 @@ export default {
|
||||
showEmojiReaction: '投稿にリアクションされた絵文字を表示する',
|
||||
others: 'その他',
|
||||
keepOpenPostForm: '投稿後も投稿欄を開いたままにする',
|
||||
showImagesByDefault: 'デフォルトで画像を読み込む',
|
||||
showMediaByDefault: 'デフォルトで画像や動画を読み込む',
|
||||
hideNumbers: 'いいねやリポスト、フォロワーなどの数を隠す',
|
||||
},
|
||||
customEmoji: {
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { isImageUrl } from '@/utils/imageUrl';
|
||||
|
||||
const ChannelMetaSchema = z.object({
|
||||
name: z.string(),
|
||||
about: z.string().optional(),
|
||||
picture: z
|
||||
.string()
|
||||
.url()
|
||||
.refine((url) => isImageUrl(url), { message: 'not an image url' })
|
||||
.optional(),
|
||||
picture: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export type ChannelMeta = z.infer<typeof ChannelMetaSchema>;
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'assert';
|
||||
|
||||
import { describe, it } from 'vitest';
|
||||
|
||||
import { fixUrl } from '@/utils/imageUrl';
|
||||
import { fixUrl } from '@/utils/url';
|
||||
|
||||
describe('fixUrl', () => {
|
||||
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 => {
|
||||
try {
|
||||
const url = new URL(urlString);
|
||||
Reference in New Issue
Block a user