Fix Exception when grouping images in gallery

This commit is contained in:
Bojan Mojsilovic 2023-12-28 15:04:18 +01:00
parent a5a7359423
commit 9cf6bf5ffe
6 changed files with 527 additions and 332 deletions

View File

@ -44,7 +44,6 @@ const App: Component = () => {
<HomeProvider>
<ExploreProvider>
<ThreadProvider>
<input id="defocus" class={styles.invisible}/>
<Router />
</ThreadProvider>
</ExploreProvider>

View File

@ -1,29 +1,18 @@
import { useIntl } from '@cookbook/solid-intl';
import { Component, createEffect, createSignal, For, Match, Show, Switch } from 'solid-js';
import { useAccountContext } from '../../contexts/AccountContext';
import { useSettingsContext } from '../../contexts/SettingsContext';
import { zapNote } from '../../lib/zap';
import { userName } from '../../stores/profile';
import { toastZapFail, zapCustomOption } from '../../translations';
import { PrimalNote } from '../../types/primal';
import { debounce } from '../../utils';
import { Component, createEffect, createSignal } from 'solid-js';
import Modal from '../Modal/Modal';
import { useToastContext } from '../Toaster/Toaster';
import { base64 } from '@scure/base';
import { nip19, utils } from 'nostr-tools';
import { nip19 } from 'nostr-tools';
import { login as tLogin, pin as tPin, actions as tActions } from '../../translations';
import { pin as tPin, actions as tActions } from '../../translations';
import styles from './EnterPinModal.module.scss';
import { hookForDev } from '../../lib/devTools';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import ButtonLink from '../Buttons/ButtonLink';
import { useNavigate } from '@solidjs/router';
import TextInput from '../TextInput/TextInput';
import ButtonSecondary from '../Buttons/ButtonSecondary';
import { decryptWithPin, encryptWithPin, setCurrentPin } from '../../lib/PrimalNostr';
import { decryptWithPin, setCurrentPin } from '../../lib/PrimalNostr';
const EnterPinModal: Component<{
id?: string,
@ -43,9 +32,6 @@ const EnterPinModal: Component<{
const decWithPin = async () => {
const val = props.valueToDecrypt || '';
const dec = await decryptWithPin(pin(), val);
// console.log('ENCODED: ', dec);
// console.log('PIN: ', pin());
// console.log('DECODE: ', decryptWithPin);
return dec;
};

View File

@ -27,10 +27,10 @@ const FeedSelect: Component<{ isPhone?: boolean, id?: string}> = (props) => {
const selectFeed = (option: FeedOption) => {
const [hex, includeReplies] = option.value?.split('_') || [];
const selector = document.getElementById('defocus');
// const selector = document.getElementById('defocus');
selector?.focus();
selector?.blur();
// selector?.focus();
// selector?.blur();
if (hex && !isSelected(option)) {
const feed = findFeed(decodeURI(hex), includeReplies);

View File

@ -23,7 +23,7 @@ import {
import { truncateNpub, userName } from '../../stores/profile';
import EmbeddedNote from '../EmbeddedNote/EmbeddedNote';
import {
Component, createEffect, createSignal, For, JSXElement, onMount, Show,
Component, createSignal, For, JSXElement, onMount, Show,
} from 'solid-js';
import {
PrimalNote,
@ -47,16 +47,6 @@ import PhotoSwipeLightbox from 'photoswipe/lightbox';
const groupGridLimit = 7;
const convertHTMLEntity = (text: string) => {
const span = document.createElement('span');
return text
.replace(/&[#A-Za-z0-9]+;/gi, (entity)=> {
span.innerHTML = entity;
return span.innerText;
});
}
export const groupGalleryImages = (noteHolder: HTMLDivElement | undefined) => {
@ -147,6 +137,8 @@ const ParsedNote: Component<{
const intl = useIntl();
const media = useMediaContext();
const dev = localStorage.getItem('devMode') === 'true';
const id = () => {
// if (props.id) return props.id;
@ -155,10 +147,6 @@ const ParsedNote: Component<{
let thisNote: HTMLDivElement | undefined;
let imageGroup: string = generatePrivateKey()
let consecutiveImages: number = 0;
let imgCount = 0;
const lightbox = new PhotoSwipeLightbox({
gallery: `#${id()}`,
children: `a.image_${props.note.post.noteId}`,
@ -173,29 +161,12 @@ const ParsedNote: Component<{
lightbox.init();
});
let allImagesLoaded = false;
createEffect(() => {
if (imagesLoaded() > 0 && imagesLoaded() === imgCount && !allImagesLoaded) {
allImagesLoaded = true;
groupGalleryImages(thisNote);
}
});
const [tokens, setTokens] = createStore<string[]>([]);
const [imagesLoaded, setImagesLoaded] = createSignal(0);
let wordsDisplayed = 0;
const [wordsDisplayed, setWordsDisplayed] = createSignal(0);
const shouldShowToken = () => {
if (!props.shorten) return true;
if (wordsDisplayed < shortNoteWords) {
return true;
}
return false;
const isNoteTooLong = () => {
return props.shorten && wordsDisplayed() > shortNoteWords;
};
const parseContent = () => {
@ -208,20 +179,41 @@ const ParsedNote: Component<{
setTokens(() => [...tokens]);
}
const parseToken: (token: string) => JSXElement = (token: string) => {
type NoteContent = {
type: string,
tokens: string[],
meta?: Record<string, any>,
};
const [content, setContent] = createStore<NoteContent[]>([]);
const updateContent = (contentArray: NoteContent[], type: string, token: string, meta?: Record<string, any>) => {
if (contentArray.length > 0 && contentArray[contentArray.length -1].type === type) {
setContent(content.length -1, 'tokens' , (els) => [...els, token]);
meta && setContent(content.length -1, 'meta' , () => ({ ...meta }));
return;
}
setContent(content.length, () => ({ type, tokens: [token], meta }));
}
let lastSignificantContent = 'text';
const parseToken = (token: string) => {
if (token === '__LB__') {
return <br />;
lastSignificantContent !== 'image' && updateContent(content, 'linebreak', token);
return;
}
if (token === '__SP__') {
return <> </>;
lastSignificantContent !== 'image' && updateContent(content, 'text', ' ');
return;
}
wordsDisplayed++;
if (isInterpunction(token)) {
return <span>{token}</span>;
lastSignificantContent = 'text';
updateContent(content, 'text', token);
return;
}
if (isUrl(token)) {
@ -234,26 +226,170 @@ const ParsedNote: Component<{
if (matched) {
const suffix = token.substring(matched.length + index, token.length);
return <>{parseToken(prefix)}{parseToken(matched)}{parseToken(suffix)}</>;
parseToken(prefix);
parseToken(matched);
parseToken(suffix);
return;
} else {
return <>{parseToken(prefix)}{parseToken(token.slice(index))}</>;
parseToken(prefix);
parseToken(token.slice(index));
return;
}
}
if (!props.ignoreMedia) {
if (isImage(token)) {
imgCount++;
consecutiveImages++;
const dev = localStorage.getItem('devMode') === 'true';
lastSignificantContent = 'image';
updateContent(content, 'image', token);
return;
}
if (isMp4Video(token)) {
lastSignificantContent = 'video';
updateContent(content, 'video', token, { videoType: 'video/mp4'});
return;
}
if (isOggVideo(token)) {
lastSignificantContent = 'video';
updateContent(content, 'video', token, { videoType: 'video/ogg'});
return;
}
if (isWebmVideo(token)) {
lastSignificantContent = 'video';
updateContent(content, 'video', token, { videoType: 'video/webm'});
return;
}
if (isYouTube(token)) {
lastSignificantContent = 'youtube';
updateContent(content, 'youtube', token);
return;
}
if (isSpotify(token)) {
lastSignificantContent = 'spotify';
updateContent(content, 'spotify', token);
return;
}
if (isTwitch(token)) {
lastSignificantContent = 'twitch';
updateContent(content, 'twitch', token);
return;
}
if (isMixCloud(token)) {
lastSignificantContent = 'mixcloud';
updateContent(content, 'mixcloud', token);
return;
}
if (isSoundCloud(token)) {
lastSignificantContent = 'soundcloud';
updateContent(content, 'soundcloud', token);
return;
}
if (isAppleMusic(token)) {
lastSignificantContent = 'applemusic';
updateContent(content, 'applemusic', token);
return;
}
if (isWavelake(token)) {
lastSignificantContent = 'wavelake';
updateContent(content, 'wavelake', token);
return;
}
}
if (props.noLinks === 'text') {
lastSignificantContent = 'text';
updateContent(content, 'text', token);
return;
}
lastSignificantContent = 'link';
updateContent(content, 'link', token);
return;
}
if (isNoteMention(token)) {
lastSignificantContent = 'notemention';
updateContent(content, 'notemention', token);
return;
}
if (isUserMention(token)) {
lastSignificantContent = 'usermention';
updateContent(content, 'usermention', token);
return;
}
if (isTagMention(token)) {
lastSignificantContent = 'tagmention';
updateContent(content, 'tagmention', token);
return;
}
if (isHashtag(token)) {
lastSignificantContent = 'hashtag';
updateContent(content, 'hashtag', token);
return;
}
lastSignificantContent = 'text';
updateContent(content, 'text', token);
return;
};
const generateContent = () => {
parseContent();
for (let i=0; i<tokens.length; i++) {
const token = tokens[i];
parseToken(token);
}
};
const renderLinebreak = (item: NoteContent) => {
if (isNoteTooLong()) return;
// Allow only one consecutive linebreak
return <br />
};
const renderText = (item: NoteContent) => {
return <For each={item.tokens}>
{token => {
if (isNoteTooLong()) return;
if (token.trim().length > 0) {
setWordsDisplayed(w => w + 1);
}
return token
}}
</For>;
};
const renderImage = (item: NoteContent) => {
const groupCount = item.tokens.length;
const imageGroup = generatePrivateKey();
if (groupCount === 1) {
if (isNoteTooLong()) return;
const token = item.tokens[0];
let image = media?.actions.getMedia(token, 'o');
const url = image?.media_url || getMediaUrlDefault(token);
if (consecutiveImages > 1) {
// There are consecutive images, so reduce the impact of each image in order to show them grouped
wordsDisplayed += 10;
} else {
wordsDisplayed += shortMentionInWords
}
// Images tell a 100 words :)
setWordsDisplayed(w => w + 100);
return <NoteImage
class={`noteimage image_${props.note.post.noteId}`}
@ -262,16 +398,43 @@ const ParsedNote: Component<{
media={image}
width={514}
imageGroup={imageGroup}
onImageLoaded={() => setImagesLoaded(i => i+1)}
shortHeight={props.shorten}
/>;
/>
}
consecutiveImages = 0;
imageGroup = generatePrivateKey();
const gridClass = groupCount < groupGridLimit ? `grid-${groupCount}` : 'grid-large';
return <div class={`imageGrid ${gridClass}`}>
<For each={item.tokens}>
{(token, index) => {
if (isNoteTooLong()) return;
let image = media?.actions.getMedia(token, 'o');
const url = image?.media_url || getMediaUrlDefault(token);
// There are consecutive images, so reduce the impact of each image in order to show them grouped
setWordsDisplayed(w => w + 10 * groupCount);
return <NoteImage
class={`noteimage_gallery image_${props.note.post.noteId} cell_${index()}`}
src={url}
isDev={dev}
media={image}
width={514}
imageGroup={imageGroup}
shortHeight={props.shorten}
plainBorder={true}
/>
}}
</For>
</div>
}
const renderVideo = (item: NoteContent) => {
return <For each={item.tokens}>{
(token) => {
if (isNoteTooLong()) return;
if (isMp4Video(token)) {
wordsDisplayed += shortMentionInWords;
let mVideo = media?.actions.getMedia(token, 'o');
let h: number | undefined = undefined;
@ -284,74 +447,38 @@ const ParsedNote: Component<{
h = h > 680 ? 680 : h;
}
// const h = mVideo ? mVideo?.h > 524 ? 524 * mVideo?.h / mVideo?.w : mVideo?.h : undefined;
// const w = mVideo ? mVideo?.w > 524 ? 524 : mVideo?.w : undefined;
const klass = mVideo ? 'w-cen' : 'w-max';
let klass = mVideo ? 'w-cen' : 'w-max';
const video = <video class={klass} width={w} height={h} controls muted={true} ><source src={token} type="video/mp4" /></video>;
media?.actions.addVideo(video as HTMLVideoElement);
return video;
if (dev && !mVideo) {
klass += ' redBorder';
}
if (isOggVideo(token)) {
wordsDisplayed += shortMentionInWords;
let mVideo = media?.actions.getMedia(token, 'o');
setWordsDisplayed(w => w + shortMentionInWords);
let h: number | undefined = undefined;
let w: number | undefined = undefined;
if (mVideo) {
const ratio = mVideo.w / mVideo.h;
h = (524 / ratio);
w = h > 680 ? 680 * ratio : 524;
h = h > 680 ? 680 : h;
}
// const h = mVideo ? mVideo?.h > 524 ? 524 * mVideo?.h / mVideo?.w : mVideo?.h : undefined;
// const w = mVideo ? mVideo?.w > 524 ? 524 : mVideo?.w : undefined;
const klass = mVideo ? 'w-cen' : 'w-max';
const video =
<video
const video = <video
class={klass}
width={w}
height={h}
controls
muted={true}
>
<source src={token} type="video/ogg" />
<source src={token} type={item.meta?.videoType} />
</video>;
media?.actions.addVideo(video as HTMLVideoElement);
return video;
}
if (isWebmVideo(token)) {
wordsDisplayed += shortMentionInWords;
let mVideo = media?.actions.getMedia(token, 'o');
let h: number | undefined = undefined;
let w: number | undefined = undefined;
if (mVideo) {
const ratio = mVideo.w / mVideo.h;
h = (524 / ratio);
w = h > 680 ? 680 * ratio : 524;
h = h > 680 ? 680 : h;
}</For>;
}
// const h = mVideo ? mVideo?.h > 524 ? 524 * mVideo?.h / mVideo?.w : mVideo?.h : undefined;
// const w = mVideo ? mVideo?.w > 524 ? 524 : mVideo?.w : undefined;
const klass = mVideo ? 'w-cen' : 'w-max';
const renderYouTube = (item: NoteContent) => {
const video = <video class={klass} width={w} height={h} controls muted={true} ><source src={token} type="video/webm" /></video>;
media?.actions.addVideo(video as HTMLVideoElement);
return video;
}
return <For each={item.tokens}>
{(token) => {
if (isNoteTooLong()) return;
if (isYouTube(token)) {
wordsDisplayed += shortMentionInWords;
setWordsDisplayed(w => w + shortMentionInWords);
const youtubeId = isYouTube(token) && RegExp.$1;
@ -365,10 +492,16 @@ const ParsedNote: Component<{
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
></iframe>;
}
}}
</For>
};
if (isSpotify(token)) {
wordsDisplayed += shortMentionInWords;
const renderSpotify = (item: NoteContent) => {
return <For each={item.tokens}>
{(token) => {
if (isNoteTooLong()) return;
setWordsDisplayed(w => w + shortMentionInWords);
const convertedUrl = token.replace(/\/(track|album|playlist|episode)\/([a-zA-Z0-9]+)/, "/embed/$1/$2");
@ -382,10 +515,16 @@ const ParsedNote: Component<{
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
loading="lazy"
></iframe>;
}
}}
</For>
};
if (isTwitch(token)) {
wordsDisplayed += shortMentionInWords;
const renderTwitch = (item: NoteContent) => {
return <For each={item.tokens}>
{(token) => {
if (isNoteTooLong()) return;
setWordsDisplayed(w => w + shortMentionInWords);
const channel = token.split("/").slice(-1);
@ -397,10 +536,16 @@ const ParsedNote: Component<{
className="w-max"
allowFullScreen
></iframe>;
}
}}
</For>
};
if (isMixCloud(token)) {
wordsDisplayed += shortMentionInWords;
const renderMixCloud = (item: NoteContent) => {
return <For each={item.tokens}>
{(token) => {
if (isNoteTooLong()) return;
setWordsDisplayed(w => w + shortMentionInWords);
const feedPath = (isMixCloud(token) && RegExp.$1) + "%2F" + (isMixCloud(token) && RegExp.$2);
@ -414,10 +559,16 @@ const ParsedNote: Component<{
src={`https://www.mixcloud.com/widget/iframe/?hide_cover=1&feed=%2F${feedPath}%2F`}
></iframe>
</div>;
}
}}
</For>
};
if (isSoundCloud(token)) {
wordsDisplayed += shortMentionInWords;
const renderSoundCloud = (item: NoteContent) => {
return <For each={item.tokens}>
{(token) => {
if (isNoteTooLong()) return;
setWordsDisplayed(w => w + shortMentionInWords);
return <iframe
width="100%"
@ -427,10 +578,16 @@ const ParsedNote: Component<{
allow="autoplay"
src={`https://w.soundcloud.com/player/?url=${token}`}
></iframe>;
}
}}
</For>
};
if (isAppleMusic(token)) {
wordsDisplayed += shortMentionInWords;
const renderAppleMusic = (item: NoteContent) => {
return <For each={item.tokens}>
{(token) => {
if (isNoteTooLong()) return;
setWordsDisplayed(w => w + shortMentionInWords);
const convertedUrl = token.replace("music.apple.com", "embed.music.apple.com");
const isSongLink = /\?i=\d+$/.test(convertedUrl);
@ -444,10 +601,16 @@ const ParsedNote: Component<{
sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-storage-access-by-user-activation allow-top-navigation-by-user-activation"
src={convertedUrl}
></iframe>;
}
}}
</For>
};
if (isWavelake(token)) {
wordsDisplayed += shortMentionInWords;
const renderWavelake = (item: NoteContent) => {
return <For each={item.tokens}>
{(token) => {
if (isNoteTooLong()) return;
setWordsDisplayed(w => w + shortMentionInWords);
const convertedUrl = token.replace(/(?:player\.|www\.)?wavlake\.com/, "embed.wavlake.com");
@ -460,12 +623,14 @@ const ParsedNote: Component<{
frameBorder="0"
loading="lazy"
></iframe>;
}
}
}}
</For>
};
if (props.noLinks === 'text') {
return <span class="whole">{token}</span>;
}
const renderLinks = (item: NoteContent) => {
return <For each={item.tokens}>
{(token) => {
if (isNoteTooLong()) return;
const preview = getLinkPreview(token);
@ -478,21 +643,25 @@ const ParsedNote: Component<{
);
if (hasMinimalPreviewData) {
wordsDisplayed += shortMentionInWords;
setWordsDisplayed(w => w + shortMentionInWords);
return <LinkPreview preview={preview} bordered={props.isEmbeded} />;
}
setWordsDisplayed(w => w + 1);
return <span data-url={token}><a link href={token.toLowerCase()} target="_blank" >{token}</a></span>;
}
}}
</For>
};
consecutiveImages = 0;
imageGroup = generatePrivateKey();
const renderNoteMention = (item: NoteContent) => {
return <For each={item.tokens}>
{(token) => {
if (isNoteTooLong()) return;
if (isNoteMention(token)) {
let [_, id] = token.split(':');
if (!id) {
return token;
return <>{token}</>;
}
let end = '';
@ -524,7 +693,7 @@ const ParsedNote: Component<{
link = <A href={path}>{token}</A>;
if (ment) {
wordsDisplayed += shortMentionInWords;
setWordsDisplayed(w => w + shortMentionInWords);
link = <div>
<EmbeddedNote
@ -536,17 +705,25 @@ const ParsedNote: Component<{
}
} catch (e) {
setWordsDisplayed(w => w + 1);
link = <span class={styles.error}>{token}</span>;
}
return <span class="whole"> {link}{end}</span>;
}
return link;}}
</For>
};
const renderUserMention = (item: NoteContent) => {
return <For each={item.tokens}>
{(token) => {
if (isNoteTooLong()) return;
setWordsDisplayed(w => w + 1);
if (isUserMention(token)) {
let [_, id] = token.split(':');
if (!id) {
return token;
return <>{token}</>;
}
let end = '';
@ -582,17 +759,23 @@ const ParsedNote: Component<{
<><A href={path}>@{label}</A>{end}</> :
<>{MentionedUserLink({ user })}{end}</>;
}
return <span class="whole"> {link}</span>;
return link;
} catch (e) {
return <span class={styles.error}> {token}</span>;
}
}
}}
</For>
};
const renderTagMention = (item: NoteContent) => {
return <For each={item.tokens}>
{(token) => {
if (isNoteTooLong()) return;
setWordsDisplayed(w => w + 1);
if (isTagMention(token)) {
let t = `${token}`;
let end = t[t.length - 1];
if ([',', '?', ';', '!'].some(x => end === x)) {
@ -628,7 +811,7 @@ const ParsedNote: Component<{
embeded = <><A href={path}>{noteId}</A>{end}</>;
if (ment) {
wordsDisplayed += shortMentionInWords;
setWordsDisplayed(w => w + shortMentionInWords - 1);
embeded = <div>
<EmbeddedNote
@ -661,12 +844,19 @@ const ParsedNote: Component<{
<><A href={path}>@{label}</A>{end}</> :
<>{MentionedUserLink({ user })}{end}</>;
}
return <span> {link}</span>;
}
}
}}
</For>
};
const renderHashtag = (item: NoteContent) => {
return <For each={item.tokens}>
{(token) => {
if (isNoteTooLong()) return;
setWordsDisplayed(w => w + 1);
if (isHashtag(token)) {
let [_, term] = token.split('#');
let end = '';
@ -683,25 +873,46 @@ const ParsedNote: Component<{
<A href={`/search/%23${term}`}>#{term}</A>;
return <span class="whole"> {embeded}{end}</span>;
}}
</For>
};
const renderContent = (item: NoteContent) => {
const renderers: Record<string, (item: NoteContent) => JSXElement> = {
linebreak: renderLinebreak,
text: renderText,
image: renderImage,
video: renderVideo,
youtube: renderYouTube,
spotify: renderSpotify,
twitch: renderTwitch,
mixcloud: renderMixCloud,
soundcloud: renderSoundCloud,
applemusic: renderAppleMusic,
wavelake: renderWavelake,
link: renderLinks,
notemention: renderNoteMention,
usermention: renderUserMention,
tagmention: renderTagMention,
hashtag: renderHashtag,
}
return <span class="whole">{convertHTMLEntity(token)}</span>;
return renderers[item.type] ?
renderers[item.type](item) :
<></>;
};
onMount(() => {
parseContent();
generateContent();
});
return (
<div ref={thisNote} id={id()} class={styles.parsedNote} >
<For each={tokens}>
{(token) =>
<Show when={shouldShowToken()}>
<>{parseToken(token)}</>
</Show>
}
<For each={content}>
{(item) => renderContent(item)}
</For>
<Show when={props.shorten && tokens.length > shortNoteWords}>
<Show when={isNoteTooLong()}>
<span class={styles.more}>
... <span class="linkish">{intl.formatMessage(actions.seeMore)}</span>
</span>

View File

@ -2,7 +2,6 @@
import { Relay } from "nostr-tools";
import { createStore } from "solid-js/store";
import LinkPreview from "../components/LinkPreview/LinkPreview";
import NoteImage from "../components/NoteImage/NoteImage";
import { appleMusicRegex, hashtagRegex, interpunctionRegex, Kind, linebreakRegex, mixCloudRegex, nostrNestsRegex, noteRegex, profileRegex, soundCloudRegex, spotifyRegex, tagMentionRegex, twitchRegex, urlRegex, urlRegexG, wavlakeRegex, youtubeRegex } from "../constants";
import { sendMessage, subscribeTo } from "../sockets";
import { MediaSize, NostrRelays, NostrRelaySignedEvent, PrimalNote, SendNoteResult } from "../types/primal";

View File

@ -125,7 +125,7 @@ const Thread: Component = () => {
scrollWindowTo(rect.top - header - banner);
// repliesHolder.setAttribute('style', `height: ${document.documentElement.scrollHeight}px;`)
}, 0)
}, 1000)
}
});