this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Alrighty, this is media-view layout

+440 -53
+72
src/app.css
··· 209 209 .timeline { 210 210 margin: 0 auto; 211 211 padding: 0; 212 + 213 + &.timeline-media { 214 + --grid-gap: 8px; 215 + display: grid; 216 + grid-template-columns: 1fr 1fr; 217 + grid-auto-rows: fit-content; 218 + gap: var(--grid-gap); 219 + padding: var(--grid-gap); 220 + 221 + &:not(#columns &) { 222 + background-color: var(--bg-faded-color); 223 + } 224 + 225 + @media (min-width: 40em) { 226 + &:not(#columns &) { 227 + --grid-gap: 16px; 228 + grid-template-columns: 1fr 1fr 1fr; 229 + 230 + @media (min-width: 40em) { 231 + width: 95vw; 232 + max-width: calc(320px * 3.3); 233 + transform: translateX(calc(-50% + var(--main-width) / 2)); 234 + } 235 + } 236 + 237 + #columns & { 238 + padding-inline: 0; 239 + } 240 + } 241 + 242 + li { 243 + padding: 0 !important; 244 + margin: 0 !important; 245 + border: 0 !important; 246 + overflow: visible !important; 247 + background-color: transparent !important; 248 + box-shadow: none !important; 249 + } 250 + 251 + @supports (grid-template-rows: masonry) { 252 + grid-template-rows: masonry; 253 + masonry-auto-flow: pack; 254 + 255 + .media-post a { 256 + aspect-ratio: revert !important; 257 + 258 + video, 259 + img, 260 + audio { 261 + min-height: 88px; /* for extreme dimensions */ 262 + } 263 + } 264 + } 265 + } 212 266 } 213 267 .timeline.grow { 214 268 /* min-height: 100vh; ··· 1676 1730 ).danger 1677 1731 .icon { 1678 1732 opacity: 1; 1733 + } 1734 + 1735 + .szh-menu 1736 + .szh-menu__item--type-checkbox:not(.szh-menu__item--disabled):not( 1737 + .szh-menu__item--hover 1738 + ) { 1739 + .icon { 1740 + opacity: 0.15; 1741 + } 1742 + 1743 + &.szh-menu__item--checked { 1744 + color: var(--link-color); 1745 + 1746 + .icon { 1747 + opacity: 1; 1748 + color: inherit; 1749 + } 1750 + } 1679 1751 } 1680 1752 1681 1753 .szh-menu .menu-wrap {
+1
src/cloak-mode.css
··· 25 25 } 26 26 27 27 .status :is(img, video, audio), 28 + .media-post .media, 28 29 .avatar, 29 30 .emoji, 30 31 .header-banner {
+1
src/components/icon.jsx
··· 102 102 keyboard: () => import('@iconify-icons/mingcute/keyboard-line'), 103 103 cloud: () => import('@iconify-icons/mingcute/cloud-line'), 104 104 month: () => import('@iconify-icons/mingcute/calendar-month-line'), 105 + media: () => import('@iconify-icons/mingcute/photo-album-line'), 105 106 }; 106 107 107 108 function Icon({
+87
src/components/media-post.css
··· 1 + .media-post { 2 + --item-radius: 16px; 3 + position: relative; 4 + animation: appear-smooth 1s ease-out; 5 + 6 + &:is(.filtered, .has-spoiler) :is(img, video) { 7 + filter: blur(32px); 8 + image-rendering: crisp-edges; 9 + image-rendering: pixelated; 10 + animation: none !important; 11 + } 12 + 13 + &.filtered[data-filtered-text]:before, 14 + &.has-spoiler[data-spoiler-text]:before { 15 + pointer-events: none; 16 + content: attr(data-spoiler-text); 17 + position: absolute; 18 + top: 0; 19 + left: 0; 20 + z-index: 1; 21 + background-color: var(--bg-blur-color); 22 + margin: 8px; 23 + padding: 4px 6px; 24 + border-radius: calc(var(--item-radius) / 2); 25 + font-size: 90%; 26 + border: var(--hairline-width) dashed var(--bg-color); 27 + 28 + > * { 29 + pointer-events: none; 30 + } 31 + } 32 + 33 + .media { 34 + border-radius: var(--item-radius); 35 + overflow: hidden; 36 + position: relative; 37 + display: block; 38 + aspect-ratio: 1 !important; 39 + 40 + &:before { 41 + position: absolute; 42 + inset: 0; 43 + content: ''; 44 + border: 1px solid var(--outline-color); 45 + border-radius: inherit; 46 + } 47 + 48 + &:not(.media-audio) { 49 + background-color: var(--average-color, var(--media-bg-color)); 50 + } 51 + 52 + @media (hover: hover) { 53 + &:hover { 54 + --drop-shadow: var(--drop-shadow-color); 55 + box-shadow: 0 8px 16px -4px var(--drop-shadow), 56 + 0 4px 8px var(--drop-shadow); 57 + 58 + @media (prefers-color-scheme: dark) { 59 + --drop-shadow: var(--link-color); 60 + } 61 + } 62 + } 63 + 64 + &:active:not(:has(button:active)) { 65 + box-shadow: none; 66 + filter: brightness(0.8); 67 + transform: scale(0.99); 68 + } 69 + 70 + video, 71 + img, 72 + audio { 73 + border-radius: 16px; 74 + /* object-fit: scale-down; */ 75 + object-fit: cover; 76 + width: 100%; 77 + height: 100%; 78 + vertical-align: top; 79 + } 80 + 81 + &:is(:hover, :focus) img { 82 + /* Less delay here to make it feel more responsive */ 83 + animation: position-object 5s ease-in-out 0.3s 5; 84 + animation-duration: var(--anim-duration, 5s); 85 + } 86 + } 87 + }
+126
src/components/media-post.jsx
··· 1 + import './media-post.css'; 2 + 3 + import { memo } from 'preact/compat'; 4 + import { useSnapshot } from 'valtio'; 5 + 6 + import states, { statusKey } from '../utils/states'; 7 + 8 + import Media from './media'; 9 + 10 + function MediaPost({ 11 + class: className, 12 + statusID, 13 + status, 14 + instance, 15 + parent, 16 + allowFilters, 17 + onMediaClick, 18 + }) { 19 + let sKey = statusKey(statusID, instance); 20 + const snapStates = useSnapshot(states); 21 + if (!status) { 22 + status = snapStates.statuses[sKey] || snapStates.statuses[statusID]; 23 + sKey = statusKey(status?.id, instance); 24 + } 25 + if (!status) { 26 + return null; 27 + } 28 + 29 + const { 30 + account: { 31 + acct, 32 + avatar, 33 + avatarStatic, 34 + id: accountId, 35 + url: accountURL, 36 + displayName, 37 + username, 38 + emojis: accountEmojis, 39 + bot, 40 + group, 41 + }, 42 + id, 43 + repliesCount, 44 + reblogged, 45 + reblogsCount, 46 + favourited, 47 + favouritesCount, 48 + bookmarked, 49 + poll, 50 + muted, 51 + sensitive, 52 + spoilerText, 53 + visibility, // public, unlisted, private, direct 54 + language, 55 + editedAt, 56 + filtered, 57 + card, 58 + createdAt, 59 + inReplyToId, 60 + inReplyToAccountId, 61 + content, 62 + mentions, 63 + mediaAttachments, 64 + reblog, 65 + uri, 66 + url, 67 + emojis, 68 + // Non-API props 69 + _deleted, 70 + _pinned, 71 + _filtered, 72 + } = status; 73 + 74 + if (!mediaAttachments?.length) { 75 + return null; 76 + } 77 + 78 + const debugHover = (e) => { 79 + if (e.shiftKey) { 80 + console.log({ 81 + ...status, 82 + }); 83 + } 84 + }; 85 + 86 + console.debug('RENDER Media post', id, status?.account.displayName); 87 + 88 + // const readingExpandSpoilers = useMemo(() => { 89 + // const prefs = store.account.get('preferences') || {}; 90 + // return !!prefs['reading:expand:spoilers']; 91 + // }, []); 92 + const hasSpoiler = spoilerText || sensitive; 93 + 94 + const Parent = parent || 'div'; 95 + 96 + return mediaAttachments.map((media, i) => { 97 + const mediaKey = `${sKey}-${media.id}`; 98 + return ( 99 + <Parent 100 + onMouseEnter={debugHover} 101 + key={mediaKey} 102 + data-spoiler-text={ 103 + spoilerText || (sensitive ? 'Sensitive media' : undefined) 104 + } 105 + data-filtered-text={_filtered ? 'Filtered' : undefined} 106 + class={` 107 + media-post 108 + ${allowFilters && _filtered ? 'filtered' : ''} 109 + ${hasSpoiler ? 'has-spoiler' : ''} 110 + `} 111 + > 112 + <Media 113 + class={className} 114 + media={media} 115 + lang={language} 116 + to={`/${instance}/s/${id}?media-only=${i + 1}`} 117 + onClick={ 118 + onMediaClick ? (e) => onMediaClick(e, i, media, status) : undefined 119 + } 120 + /> 121 + </Parent> 122 + ); 123 + }); 124 + } 125 + 126 + export default memo(MediaPost);
+18 -6
src/components/media.jsx
··· 62 62 ); 63 63 64 64 function Media({ 65 + class: className = '', 65 66 media, 66 67 to, 67 68 lang, ··· 170 171 const maxAspectHeight = 171 172 window.innerHeight * (orientation === 'portrait' ? 0.45 : 0.33); 172 173 const maxHeight = orientation === 'portrait' ? 0 : 160; 174 + const averageColorStyle = { 175 + '--average-color': rgbAverageColor && `rgb(${rgbAverageColor.join(',')})`, 176 + }; 173 177 const mediaStyles = 174 178 width && height 175 179 ? { ··· 180 184 (width / height) * Math.max(maxHeight, maxAspectHeight) 181 185 }px`, 182 186 aspectRatio: `${width} / ${height}`, 187 + ...averageColorStyle, 183 188 } 184 - : {}; 189 + : { 190 + ...averageColorStyle, 191 + }; 185 192 186 193 const longDesc = isMediaCaptionLong(description); 187 194 const showInlineDesc = ··· 233 240 <Figure> 234 241 <Parent 235 242 ref={parentRef} 236 - class={`media media-image`} 243 + class={`media media-image ${className}`} 237 244 onClick={onClick} 238 245 data-orientation={orientation} 239 246 data-has-alt={!showInlineDesc} ··· 244 251 backgroundSize: imageSmallerThanParent 245 252 ? `${width}px ${height}px` 246 253 : undefined, 254 + ...averageColorStyle, 247 255 } 248 256 : mediaStyles 249 257 } ··· 341 349 return ( 342 350 <Figure> 343 351 <Parent 344 - class={`media media-${isGIF ? 'gif' : 'video'} ${ 352 + class={`media ${className} media-${isGIF ? 'gif' : 'video'} ${ 345 353 autoGIFAnimate ? 'media-contain' : '' 346 354 }`} 347 355 data-orientation={orientation} 348 - data-formatted-duration={formattedDuration} 356 + data-formatted-duration={ 357 + !showOriginal ? formattedDuration : undefined 358 + } 349 359 data-label={isGIF && !showOriginal && !autoGIFAnimate ? 'GIF' : ''} 350 360 data-has-alt={!showInlineDesc} 351 361 // style={{ ··· 448 458 return ( 449 459 <Figure> 450 460 <Parent 451 - class="media media-audio" 452 - data-formatted-duration={formattedDuration} 461 + class={`media media-audio ${className}`} 462 + data-formatted-duration={ 463 + !showOriginal ? formattedDuration : undefined 464 + } 453 465 data-has-alt={!showInlineDesc} 454 466 onClick={onClick} 455 467 style={!showOriginal && mediaStyles}
+9 -2
src/components/shortcuts-settings.jsx
··· 105 105 pattern: '[^#]+', 106 106 }, 107 107 { 108 + text: 'Media only', 109 + name: 'media', 110 + type: 'checkbox', 111 + }, 112 + { 108 113 text: 'Instance', 109 114 name: 'instance', 110 115 type: 'text', ··· 186 191 id: 'hashtag', 187 192 title: ({ hashtag }) => hashtag, 188 193 subtitle: ({ instance }) => instance || api().instance, 189 - path: ({ hashtag, instance }) => 190 - `${instance ? `/${instance}` : ''}/t/${hashtag.split(/\s+/).join('+')}`, 194 + path: ({ hashtag, instance, media }) => 195 + `${instance ? `/${instance}` : ''}/t/${hashtag.split(/\s+/).join('+')}${ 196 + media ? '?media=1' : '' 197 + }`, 191 198 icon: 'hashtag', 192 199 }, 193 200 };
+8 -8
src/components/status.css
··· 846 846 object-fit: cover; 847 847 vertical-align: middle; 848 848 } 849 - .status .media { 849 + .media { 850 850 cursor: pointer; 851 851 852 852 &[data-has-alt] { ··· 885 885 position: relative; 886 886 background-clip: padding-box; 887 887 } 888 - .status :is(.media-video, .media-audio) .media-play { 888 + :is(.media-video, .media-audio) .media-play { 889 889 pointer-events: none; 890 890 width: 44px; 891 891 height: 44px; ··· 902 902 border-radius: 70px; 903 903 transition: transform 0.2s ease-in-out; 904 904 } 905 - .status :is(.media-video, .media-audio):hover:not(:active) .media-play { 905 + :is(.media-video, .media-audio):hover:not(:active) .media-play { 906 906 transform: translate(-50%, -50%) scale(1.1); 907 907 } 908 - .status :is(.media-video, .media-audio)[data-formatted-duration]:after { 908 + :is(.media-video, .media-audio)[data-formatted-duration]:after { 909 909 font-size: 12px; 910 910 pointer-events: none; 911 911 content: attr(data-formatted-duration); ··· 918 918 border-radius: 4px; 919 919 padding: 0 4px; 920 920 } 921 - .status .media-audio[data-formatted-duration]:after { 921 + .media-audio[data-formatted-duration]:after { 922 922 content: '♬ ' attr(data-formatted-duration); 923 923 } 924 - .status .media-gif[data-label]:not(:hover):after { 924 + .media-gif[data-label]:not(:hover):after { 925 925 font-size: 12px; 926 926 font-weight: bold; 927 927 pointer-events: none; ··· 953 953 .status .media-audio audio { 954 954 width: 100%; 955 955 } */ 956 - .status .media-audio { 956 + .media-audio { 957 957 width: 100%; 958 958 height: 100%; 959 959 background-image: radial-gradient( 960 960 circle at center center, 961 - var(--bg-color), 961 + transparent, 962 962 var(--bg-faded-color) 963 963 ), 964 964 repeating-radial-gradient(
+79 -29
src/components/timeline.jsx
··· 13 13 14 14 import Icon from './icon'; 15 15 import Link from './link'; 16 + import Media from './media'; 17 + import MediaPost from './media-post'; 16 18 import NavMenu from './nav-menu'; 17 19 import Status from './status'; 18 20 ··· 39 41 timelineStart, 40 42 allowFilters, 41 43 refresh, 44 + view, 42 45 }) { 43 46 const snapStates = useSnapshot(states); 44 47 const [items, setItems] = useState([]); ··· 50 53 51 54 console.debug('RENDER Timeline', id, refresh); 52 55 56 + const allowGrouping = view !== 'media'; 53 57 const loadItems = useDebouncedCallback( 54 58 (firstLoad) => { 55 59 setShowNew(false); ··· 59 63 try { 60 64 let { done, value } = await fetchItems(firstLoad); 61 65 if (Array.isArray(value)) { 62 - if (boostsCarousel) { 63 - value = groupBoosts(value); 66 + if (allowGrouping) { 67 + if (boostsCarousel) { 68 + value = groupBoosts(value); 69 + } 70 + value = groupContext(value); 64 71 } 65 - value = groupContext(value); 66 72 console.log(value); 67 73 if (firstLoad) { 68 74 setItems(value); ··· 210 216 } 211 217 }, [nearReachEnd, showMore]); 212 218 219 + const prevView = useRef(view); 220 + useEffect(() => { 221 + if (prevView.current !== view) { 222 + prevView.current = view; 223 + setItems([]); 224 + } 225 + }, [view]); 226 + 213 227 const loadOrCheckUpdates = useCallback( 214 228 async ({ disableIdleCheck = false } = {}) => { 215 229 console.log('✨ Load or check updates', { ··· 346 360 )} 347 361 {!!items.length ? ( 348 362 <> 349 - <ul class="timeline"> 363 + <ul class={`timeline ${view ? `timeline-${view}` : ''}`}> 350 364 {items.map((status) => ( 351 365 <TimelineItem 352 366 status={status} ··· 354 368 useItemID={useItemID} 355 369 allowFilters={allowFilters} 356 370 key={status.id + status?._pinned} 371 + view={view} 357 372 /> 358 373 ))} 359 - {showMore && uiState === 'loading' && ( 360 - <> 361 - <li 362 - style={{ 363 - height: '20vh', 364 - }} 365 - > 366 - <Status skeleton /> 367 - </li> 368 - <li 369 - style={{ 370 - height: '25vh', 371 - }} 372 - > 373 - <Status skeleton /> 374 - </li> 375 - </> 376 - )} 374 + {showMore && 375 + uiState === 'loading' && 376 + (view === 'media' ? null : ( 377 + <> 378 + <li 379 + style={{ 380 + height: '20vh', 381 + }} 382 + > 383 + <Status skeleton /> 384 + </li> 385 + <li 386 + style={{ 387 + height: '25vh', 388 + }} 389 + > 390 + <Status skeleton /> 391 + </li> 392 + </> 393 + ))} 377 394 </ul> 378 395 {uiState === 'default' && 379 396 (showMore ? ( ··· 399 416 </> 400 417 ) : uiState === 'loading' ? ( 401 418 <ul class="timeline"> 402 - {Array.from({ length: 5 }).map((_, i) => ( 403 - <li key={i}> 404 - <Status skeleton /> 405 - </li> 406 - ))} 419 + {Array.from({ length: 5 }).map((_, i) => 420 + view === 'media' ? ( 421 + <div 422 + style={{ 423 + height: '50vh', 424 + }} 425 + /> 426 + ) : ( 427 + <li key={i}> 428 + <Status skeleton /> 429 + </li> 430 + ), 431 + )} 407 432 </ul> 408 433 ) : ( 409 434 uiState !== 'error' && <p class="ui-state">{emptyText}</p> ··· 426 451 ); 427 452 } 428 453 429 - function TimelineItem({ status, instance, useItemID, allowFilters }) { 454 + function TimelineItem({ status, instance, useItemID, allowFilters, view }) { 430 455 const { id: statusID, reblog, items, type, _pinned } = status; 431 456 const actualStatusID = reblog?.id || statusID; 432 457 const url = instance ··· 531 556 ); 532 557 }); 533 558 } 559 + 560 + const itemKey = `timeline-${statusID + _pinned}`; 561 + 562 + if (view === 'media') { 563 + return useItemID ? ( 564 + <MediaPost 565 + class="timeline-item" 566 + parent="li" 567 + key={itemKey} 568 + statusID={statusID} 569 + instance={instance} 570 + allowFilters={allowFilters} 571 + /> 572 + ) : ( 573 + <MediaPost 574 + class="timeline-item" 575 + parent="li" 576 + key={itemKey} 577 + status={status} 578 + instance={instance} 579 + allowFilters={allowFilters} 580 + /> 581 + ); 582 + } 583 + 534 584 return ( 535 - <li key={`timeline-${statusID + _pinned}`}> 585 + <li key={itemKey}> 536 586 <Link class="status-link timeline-item" to={url}> 537 587 {useItemID ? ( 538 588 <Status
+1
src/pages/account-statuses.jsx
··· 414 414 errorText="Unable to load posts" 415 415 fetchItems={fetchAccountStatuses} 416 416 useItemID 417 + view={media ? 'media' : undefined} 417 418 boostsCarousel={snapStates.settings.boostsCarousel} 418 419 timelineStart={TimelineStart} 419 420 refresh={[
+38 -8
src/pages/hashtag.jsx
··· 2 2 FocusableItem, 3 3 MenuDivider, 4 4 MenuGroup, 5 + MenuHeader, 5 6 MenuItem, 6 7 } from '@szhsin/react-menu'; 7 8 import { useEffect, useRef, useState } from 'preact/hooks'; 8 - import { useNavigate, useParams } from 'react-router-dom'; 9 + import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; 9 10 10 11 import Icon from '../components/icon'; 11 12 import Menu2 from '../components/menu2'; ··· 25 26 const TAGS_LIMIT_PER_MODE = 4; 26 27 const TOTAL_TAGS_LIMIT = TAGS_LIMIT_PER_MODE + 1; 27 28 28 - function Hashtags({ columnMode, ...props }) { 29 + function Hashtags({ media: mediaView, columnMode, ...props }) { 29 30 // const navigate = useNavigate(); 30 31 let { hashtag, ...params } = columnMode ? {} : useParams(); 31 32 if (props.hashtag) hashtag = props.hashtag; 32 33 let hashtags = hashtag.trim().split(/[\s+]+/); 33 34 hashtags.sort(); 34 35 hashtag = hashtags[0]; 36 + const [searchParams, setSearchParams] = useSearchParams(); 37 + const media = mediaView || !!searchParams.get('media'); 38 + const linkParams = media ? '?media=1' : ''; 35 39 36 40 const { masto, instance, authenticated } = api({ 37 41 instance: props?.instance || params.instance, ··· 60 64 limit: LIMIT, 61 65 any: hashtags.slice(1), 62 66 maxId: firstLoad ? undefined : maxID.current, 67 + onlyMedia: media, 63 68 }) 64 69 .next(); 65 70 const { value } = results; ··· 69 74 } 70 75 71 76 value.forEach((item) => { 72 - saveStatus(item, instance); 77 + saveStatus(item, instance, { 78 + skipThreading: media, // If media view, no need to form threads 79 + }); 73 80 }); 74 81 75 82 maxID.current = value[value.length - 1].id; ··· 136 143 fetchItems={fetchHashtags} 137 144 checkForUpdates={checkForUpdates} 138 145 useItemID 146 + view={media ? 'media' : undefined} 147 + refresh={media} 139 148 headerEnd={ 140 149 <Menu2 141 150 portal ··· 209 218 <MenuDivider /> 210 219 </> 211 220 )} 221 + <MenuHeader className="plain">Filters</MenuHeader> 222 + <MenuItem 223 + type="checkbox" 224 + checked={!!media} 225 + onClick={() => { 226 + if (media) { 227 + searchParams.delete('media'); 228 + } else { 229 + searchParams.set('media', '1'); 230 + } 231 + setSearchParams(searchParams); 232 + }} 233 + > 234 + <Icon icon="check-circle" />{' '} 235 + <span class="menu-grow">Media only</span> 236 + </MenuItem> 237 + <MenuDivider /> 212 238 <FocusableItem className="menu-field" disabled={reachLimit}> 213 239 {({ ref }) => ( 214 240 <form ··· 231 257 // ); 232 258 location.hash = instance 233 259 ? `/${instance}/t/${hashtags.join('+')}` 234 - : `/t/${hashtags.join('+')}`; 260 + : `/t/${hashtags.join('+')}${linkParams}`; 235 261 } 236 262 }} 237 263 > ··· 267 293 // : `/t/${hashtags.join('+')}`, 268 294 // ); 269 295 location.hash = instance 270 - ? `/${instance}/t/${hashtags.join('+')}` 271 - : `/t/${hashtags.join('+')}`; 296 + ? `/${instance}/t/${hashtags.join('+')}${linkParams}` 297 + : `/t/${hashtags.join('+')}${linkParams}`; 272 298 }} 273 299 > 274 300 <Icon icon="x" alt="Remove hashtag" class="danger-icon" /> ··· 287 313 type: 'hashtag', 288 314 hashtag: hashtags.join(' '), 289 315 instance, 316 + media: media ? 'on' : undefined, 290 317 }; 291 318 // Check if already exists 292 319 const exists = states.shortcuts.some( ··· 300 327 .split(/[\s+]+/) 301 328 .sort() 302 329 .join(' ') && 303 - (s.instance ? s.instance === shortcut.instance : true), 330 + (s.instance ? s.instance === shortcut.instance : true) && 331 + (s.media ? !!s.media === !!shortcut.media : true), 304 332 ); 305 333 if (exists) { 306 334 alert('This shortcut already exists'); ··· 324 352 if (newInstance) { 325 353 newInstance = newInstance.toLowerCase().trim(); 326 354 // navigate(`/${newInstance}/t/${hashtags.join('+')}`); 327 - location.hash = `/${newInstance}/t/${hashtags.join('+')}`; 355 + location.hash = `/${newInstance}/t/${hashtags.join( 356 + '+', 357 + )}${linkParams}`; 328 358 } 329 359 }} 330 360 >