diff --git a/package-lock.json b/package-lock.json index 6345cd8..fb2da72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "Alexandrite", - "version": "0.8.7", + "version": "0.8.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "Alexandrite", - "version": "0.8.7", + "version": "0.8.8", "dependencies": { "@fortawesome/fontawesome-free": "^6.4.0", "date-fns": "^2.30.0", @@ -2258,9 +2258,9 @@ "dev": true }, "node_modules/get-func-name": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", - "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", "dev": true, "engines": { "node": "*" diff --git a/package.json b/package.json index 60e5bb5..cc4e4cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "Alexandrite", - "version": "0.8.7", + "version": "0.8.8", "private": true, "scripts": { "dev": "vite dev", diff --git a/src/lib/CommunitySidebar.svelte b/src/lib/CommunitySidebar.svelte index 0846c48..c535981 100644 --- a/src/lib/CommunitySidebar.svelte +++ b/src/lib/CommunitySidebar.svelte @@ -1,43 +1,57 @@
- - - - + {#if community && communityView} + + + + -
- - {#if communityView} - - {/if} - - - - View on {communityInstance} - - +
+ + {#if communityView} + + {/if} + + + + View on {communityInstance} + + - -
-
- - {#if moderators} - - Moderators ({moderators.length}) - - {#each moderators as mod} - - {/each} - - - {/if} - -
- +
+
+ + {#if moderators} + + Moderation + + +

Moderators

+ +
+
    + {#each moderators as mod} + + {/each} +
+ {#if userModerates && !userIsHeadMod} + + Leave Mod Team + + {/if} +
+
+ {/if} +
+
+
+ {/if}
diff --git a/src/lib/ExtraActionButton.svelte b/src/lib/ExtraActionButton.svelte index 90943bf..2fe3bab 100644 --- a/src/lib/ExtraActionButton.svelte +++ b/src/lib/ExtraActionButton.svelte @@ -5,21 +5,28 @@ } -{#if action.href} - {action.text} -{:else if action.click} - +{#if !action.hidden} + {#if action.href} + {action.text} + {:else if action.click} + + {/if} {/if} diff --git a/src/lib/mod/ModContext.svelte b/src/lib/mod/ModContext.svelte index 6c9f559..6fad10d 100644 --- a/src/lib/mod/ModContext.svelte +++ b/src/lib/mod/ModContext.svelte @@ -42,6 +42,7 @@ import { setModContext, type ModAction, type ModContext } from './mod-context'; import { showPromptModal, createAutoExpireToast } from 'sheodox-ui'; import { profile } from '$lib/profiles/profiles'; + import { getCommunityContext } from '$lib/community-context/community-context'; $: client = $profile.client; $: jwt = $profile.jwt; @@ -49,6 +50,8 @@ const pending = writable(new Set()), DAY_MS = 1000 * 60 * 60 * 24; + const communityContext = getCommunityContext(); + interface PendingBan { username: string; reason: string; @@ -72,7 +75,7 @@ }); // marks an arbitrary thing as pending - export const setPending = (action: ModAction, id: number, isPending: boolean) => { + export const setPending = (action: ModAction, id: number | string, isPending: boolean) => { pending.update((p) => { const actionId = `${action}-${id}`; isPending ? p.add(actionId) : p.delete(actionId); @@ -292,6 +295,33 @@ } }; + const addMod: ModContext['addMod'] = async (opt) => { + if (!jwt) { + return; + } + + const pendingKey = `${opt.communityId}-${opt.personId}`; + + setPending('add-mod', pendingKey, true); + + try { + const res = await client.addModToCommunity({ + auth: jwt, + added: opt.added, + community_id: opt.communityId, + person_id: opt.personId + }); + + successToast(opt.added ? `Added ${opt.personName} to mods` : `Removed ${opt.personName} from mods`); + + communityContext.updateCommunity(await client.getCommunity({ auth: jwt, id: opt.communityId })); + + return res; + } finally { + setPending('add-mod', pendingKey, false); + } + }; + setModContext({ pending, banPerson, @@ -299,6 +329,7 @@ removeComment, featurePost, lockPost, - distinguishComment + distinguishComment, + addMod }); diff --git a/src/lib/mod/mod-context.ts b/src/lib/mod/mod-context.ts index 7fa2496..a0b0f83 100644 --- a/src/lib/mod/mod-context.ts +++ b/src/lib/mod/mod-context.ts @@ -1,4 +1,10 @@ -import type { BanFromCommunityResponse, CommentResponse, PostFeatureType, PostResponse } from 'lemmy-js-client'; +import type { + AddModToCommunityResponse, + BanFromCommunityResponse, + CommentResponse, + PostFeatureType, + PostResponse +} from 'lemmy-js-client'; import { getContext, setContext } from 'svelte'; import { derived, type Writable } from 'svelte/store'; @@ -22,6 +28,12 @@ export interface ModContext { }) => Promise; lockPost: (opts: { postId: number; locked: boolean }) => Promise; distinguishComment: (opts: { commentId: number; distinguished: boolean }) => Promise; + addMod: (opts: { + added: boolean; + communityId: number; + personId: number; + personName: string; + }) => Promise; } export type ModAction = @@ -31,10 +43,11 @@ export type ModAction = | 'feature-post-community' | 'feature-post-local' | 'lock-post' - | 'distinguish-comment'; + | 'distinguish-comment' + | 'add-mod'; // get a store indicating if an action is pending for a given user/post/comment etc -export const getModActionPending = (action: ModAction, id: number) => { +export const getModActionPending = (action: ModAction, id: number | string) => { const { pending } = getModContext(); return derived([pending], ([pending]) => { return pending.has(`${action}-${id}`); diff --git a/src/lib/post-loader.ts b/src/lib/post-loader.ts index c0a15d8..a8da6ea 100644 --- a/src/lib/post-loader.ts +++ b/src/lib/post-loader.ts @@ -1,7 +1,7 @@ -import { parseISO } from 'date-fns'; import type { CommentView, PostView } from 'lemmy-js-client'; import type { ApiFeedLoad } from './feed-query'; import { postViewToContentView, type ContentView, commentViewToContentView } from './content-views'; +import { parseDate } from './utils'; interface MorePage { error: boolean; @@ -15,8 +15,8 @@ export const getContentViews = (postViews: PostView[], commentViews: CommentView // to merge in both types of content and show them in some reasonable order if (type === 'Overview' && sort) { content.sort((a, b) => { - const aPublished = parseISO(a.published + 'Z').getTime(), - bPublished = parseISO(b.published + 'Z').getTime(); + const aPublished = parseDate(a.published).getTime(), + bPublished = parseDate(b.published).getTime(); if (sort === 'New') { return bPublished - aPublished; diff --git a/src/lib/profiles/profiles.ts b/src/lib/profiles/profiles.ts index decbeb6..883e082 100644 --- a/src/lib/profiles/profiles.ts +++ b/src/lib/profiles/profiles.ts @@ -47,7 +47,7 @@ profiles.subscribe((val) => { // if you're forced to use just one instance set that as the default but ignore the localStorage pinning export const defaultInstance = config.forcedInstance ? writable(config.forcedInstance) - : localStorageBackedStore(lsKeys.defaultInstance, getDefaultInstance(), 0, true); + : localStorageBackedStore(lsKeys.defaultInstance, getDefaultInstance(), { setAlways: true }); export const instance = writable(getRouteInstance()); // set the instance and go to it, used when selecting a profile on the login screen so the account diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts new file mode 100644 index 0000000..7e6f3d5 --- /dev/null +++ b/src/lib/utils.test.ts @@ -0,0 +1,24 @@ +import { it, describe, expect } from 'vitest'; +import { CrossTabMessageTypes } from './utils'; + +function assertEnumValueUniqueness(enumName: string, enm: Record) { + // reverse of the enum, `value: key` to find conflicting names + const foundValues = new Map(); + for (const [name, val] of Object.entries(enm)) { + if (foundValues.has(val)) { + throw new Error( + `Enum values for "${enumName}" are not unique, has duplicate value "${val}" for keys "${name}" and "${foundValues.get( + val + )}"`, + enm + ); + } + foundValues.set(val, name); + } +} + +describe('cross tab messages', () => { + it('message type enum values are unique', () => { + expect(() => assertEnumValueUniqueness('CrossTabMessageTypes', CrossTabMessageTypes)).not.toThrow(); + }); +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e190933..f881b4d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,7 @@ import { goto } from '$app/navigation'; -import { readable, writable } from 'svelte/store'; +import { readable, writable, type Writable } from 'svelte/store'; import { getCtrlBasedHotkeys } from './app-context'; +import { parseISO } from 'date-fns'; export function safeUrl(url: string | null) { if (!url) { @@ -20,7 +21,12 @@ export interface ExtraAction { icon: string; click?: () => unknown; variant?: 'regular' | 'solid'; + // disable the button, switch icon to spinner busy?: boolean; + // disable the button + disabled?: boolean; + // don't render the button at all + hidden?: boolean; } export const localStorageSet = (key: string, value?: T) => { @@ -47,7 +53,14 @@ export const localStorageGet = (key: string, defaultValue: T) => { } }; -export const localStorageBackedStore = (lsKey: string, defaultValue: T, schemaVersion = 0, setAlways = false) => { +export const localStorageBackedStore = ( + lsKey: string, + defaultValue: T, + opts?: { schemaVersion?: number; setAlways?: boolean } +): Writable => { + const schemaVersion = opts?.schemaVersion ?? 0; + const setAlways = opts?.setAlways ?? false; + const key = `alexandrite-setting-${lsKey}-v${schemaVersion}`; let value = defaultValue; @@ -57,27 +70,50 @@ export const localStorageBackedStore = (lsKey: string, defaultValue: T, schem value = JSON.parse(item); } } catch (e) { - /* ignore, use default */ + // ignore, use default, but if they want the value always set, set it. + // this is used to set the default instance if one isn't set, so following + // a link from a friend will keep you on that instance next time unless changed + if (setAlways && defaultValue && typeof localStorage !== 'undefined') { + localStorage.setItem(key, JSON.stringify(defaultValue)); + } } - const store = writable(value); - // whenever the value changes, write it to local storage - // TODO listen to storage events and update from other tabs! - let initialized = setAlways; - store.subscribe((val) => { - // don't do it on the first subscribe callback, the value hasn't changed - if (!initialized) { - initialized = true; + const store = writable(value, (set) => { + if (typeof window === 'undefined') { return; } - try { - localStorage.setItem(key, JSON.stringify(val)); - } catch (e) { - /* sveltekit tooling in dev throws on localStorage */ + + // sync the store's value to changes on other tabs, this event is fired when + // local/sessionStorage is changed in another browser tab. this lets us coordinate + // things between tabs, so things like the unreadCount always match in other tabs + function onStorage(e: StorageEvent) { + if (e.storageArea === localStorage && e.key === key) { + const newVal = localStorageGet(key, defaultValue); + + set(newVal); + } } + + window.addEventListener('storage', onStorage); + + return () => { + window.removeEventListener('storage', onStorage); + }; }); - return store; + return { + ...store, + // wrap 'set' so we can listen to changes without a subscriber + set: (val) => { + try { + localStorage.setItem(key, JSON.stringify(val)); + } catch (e) { + /* sveltekit tooling in dev throws on localStorage */ + } + + store.set(val); + } + }; }; export class Throttler { @@ -310,3 +346,87 @@ export function submitOnHardEnter(formEl: HTMLFormElement) { } }; } + +// lemmy dates are missing the 'Z', but this will be fixed at some point, +// utility function to handle both cases +export function parseDate(isoStr: string) { + if (!isoStr.includes('Z')) { + isoStr += 'Z'; + } + return parseISO(isoStr); +} + +// all the different valid types of message that the message bus can handle +export enum CrossTabMessageTypes { + Test = 'test', + Test2 = 'test2' +} + +interface CrossTabMessage { + type: CrossTabMessageTypes; + value: ReturnType; +} + +class CrossTabMessageBus { + key = 'alexandrite-tab-message-bus'; + listeners = new Map(); + destroyFns: (() => void)[] = []; + + constructor() { + if (typeof window === 'undefined') { + return; + } + + const onStorage = (e: StorageEvent) => { + if (e.storageArea === localStorage && e.key === this.key && e.newValue) { + const msg: CrossTabMessage = JSON.parse(e.newValue); + const listeners = this.listeners.get(msg.type) ?? []; + + for (const listener of listeners) { + listener(msg.value); + } + } + }; + window.addEventListener('storage', onStorage); + + this.destroyFns.push(() => window.removeEventListener('storage', onStorage)); + } + on(type: CrossTabMessageTypes, fn: (val: T) => unknown) { + const listeners = this.listeners.get(type) ?? []; + listeners.push(fn); + this.listeners.set(type, listeners); + } + off(type: CrossTabMessageTypes, fn: (val: T) => unknown) { + const listeners = this.listeners.get(type) || [], + index = listeners.indexOf(fn); + + if (index > -1) { + index.splice(index, 1); + } + + this.listeners.set(type, listeners); + } + emit(type: CrossTabMessageTypes, value: CrossTabMessage['value']) { + if (typeof window === 'undefined') { + return; + } + localStorage.setItem(this.key, JSON.stringify({ type, value })); + } + destroy() { + for (const fn of this.destroyFns) { + fn(); + } + + this.destroyFns = []; + } +} + +export const crossTabEventBus = new CrossTabMessageBus(); + +// crossTabEventBus.on(CrossTabMessageTypes.Test, (val) => { +// console.log(val); +// }); +// +// for (let i = 0; i < 100; i++) { +// crossTabEventBus.emit(CrossTabMessageTypes.Test, 'hello!!! ' + i); +// } diff --git a/src/routes/(app)/[instance]/+layout.svelte b/src/routes/(app)/[instance]/+layout.svelte index 1627585..561b635 100644 --- a/src/routes/(app)/[instance]/+layout.svelte +++ b/src/routes/(app)/[instance]/+layout.svelte @@ -88,11 +88,11 @@
- - + + - - + +
{#if showAccountsSelector} @@ -120,7 +120,7 @@ import Spinner from '$lib/Spinner.svelte'; import IconLink from '$lib/IconLink.svelte'; import Logo from '$lib/Logo.svelte'; - import { writable, type Unsubscriber, readable } from 'svelte/store'; + import { writable, type Unsubscriber, readable, derived } from 'svelte/store'; import IconButton from '$lib/IconButton.svelte'; import { setSettingsContext } from '$lib/settings-context'; import HeaderUserMenu from './HeaderUserMenu.svelte'; @@ -251,6 +251,9 @@ window.addEventListener('resize', update); return () => window.removeEventListener('resize', update); + }), + userId: derived(siteMeta, (siteMeta) => { + return siteMeta.my_user?.local_user_view.person.id ?? null; }) }); diff --git a/src/routes/(app)/[instance]/inbox/+page.svelte b/src/routes/(app)/[instance]/inbox/+page.svelte index 55be176..aecdacb 100644 --- a/src/routes/(app)/[instance]/inbox/+page.svelte +++ b/src/routes/(app)/[instance]/inbox/+page.svelte @@ -70,13 +70,12 @@ import ContentViewProvider from '$lib/ContentViewProvider.svelte'; import InboxReadButton from '$lib/InboxReadButton.svelte'; import type { PageData } from './$types'; - import { parseISO } from 'date-fns'; import { getAppContext } from '$lib/app-context'; import Title from '$lib/Title.svelte'; import VirtualFeed from '$lib/VirtualFeed.svelte'; import { feedLoader } from '$lib/post-loader'; import type { CommentSortType } from 'lemmy-js-client'; - import { createStatefulAction, navigateOnChange } from '$lib/utils'; + import { createStatefulAction, navigateOnChange, parseDate } from '$lib/utils'; import { invalidateAll } from '$app/navigation'; import { createContentViewStore, @@ -201,8 +200,8 @@ if (data.query.listing === 'All') { return [...replies, ...mentions, ...messages].sort((a, b) => { - const aPublished = parseISO(a.published + 'Z').getTime(), - bPublished = parseISO(b.published + 'Z').getTime(); + const aPublished = parseDate(a.published).getTime(), + bPublished = parseDate(b.published).getTime(); if (data.query.sort === 'New') { return bPublished - aPublished; diff --git a/src/routes/(app)/[instance]/modlog/view/+page.svelte b/src/routes/(app)/[instance]/modlog/view/+page.svelte index d54bc06..c766956 100644 --- a/src/routes/(app)/[instance]/modlog/view/+page.svelte +++ b/src/routes/(app)/[instance]/modlog/view/+page.svelte @@ -12,7 +12,11 @@

{#if data.communityView} /c/{data.communityView.community.title} - {:else if data.targetUser} + {/if} + {#if data.communityView && data.targetUser} + · + {/if} + {#if data.targetUser} Actions on /u/ {/if}

diff --git a/src/routes/(app)/[instance]/modlog/view/ModlogAction.svelte b/src/routes/(app)/[instance]/modlog/view/ModlogAction.svelte index 266c1c1..170aec8 100644 --- a/src/routes/(app)/[instance]/modlog/view/ModlogAction.svelte +++ b/src/routes/(app)/[instance]/modlog/view/ModlogAction.svelte @@ -63,7 +63,7 @@ {/if} {#if modlog.expires} - Expires: {parseExpiration(modlog.expires)} +

Expires:

{/if} @@ -84,7 +84,6 @@ import type { ContentViewModlog } from '$lib/content-views'; import { Icon, Stack } from 'sheodox-ui'; import UserLink from '$lib/UserLink.svelte'; - import { parseISO } from 'date-fns'; import RelativeTime from '$lib/RelativeTime.svelte'; import CommunityBadges from '$lib/feeds/posts/CommunityBadges.svelte'; import CommunityLink from '$lib/CommunityLink.svelte'; @@ -104,20 +103,6 @@ return $siteMeta.admins.some((admin) => admin.person.id === mod.id) ? 'Admin' : 'Mod'; } - const dateFmt = new Intl.DateTimeFormat(navigator.languages[0], { - dateStyle: 'medium', - timeStyle: 'short' - }); - - function parseExpiration(expires: string) { - try { - return dateFmt.format(parseISO(expires + 'Z')); - } catch (e) { - // it's possible to ban someone with an expiration that's unparsably far in the future - return 'Unknown'; - } - } - function getAction(modlog: ContentViewModlog) { if (modlog.type === 'mod-ban') { return modlog.bannedInstance diff --git a/src/routes/(app)/[instance]/reports/+page.svelte b/src/routes/(app)/[instance]/reports/+page.svelte index 0387556..89a7b50 100644 --- a/src/routes/(app)/[instance]/reports/+page.svelte +++ b/src/routes/(app)/[instance]/reports/+page.svelte @@ -55,10 +55,9 @@ import ContentViewProvider from '$lib/ContentViewProvider.svelte'; import VirtualFeed from '$lib/VirtualFeed.svelte'; import BusyButton from '$lib/BusyButton.svelte'; - import { createStatefulAction, navigateOnChange } from '$lib/utils.js'; + import { createStatefulAction, navigateOnChange, parseDate } from '$lib/utils.js'; import type { PageData } from './$types'; import { feedLoader } from '$lib/post-loader'; - import { parseISO } from 'date-fns'; import ReportedPost from './ReportedPost.svelte'; import ReportedComment from './ReportedComment.svelte'; import { writable } from 'svelte/store'; @@ -180,11 +179,11 @@ if (data.query.type === 'All') { return [...posts, ...comments].sort((a, b) => { - const aPublished = parseISO(a.published + 'Z').getTime(), - bPublished = parseISO(b.published + 'Z').getTime(); + const aPublished = parseDate(a.published).getTime(), + bPublished = parseDate(b.published).getTime(); - // oldest first, probably the most visible thing? - return aPublished - bPublished; + // newest first + return bPublished - aPublished; }); } diff --git a/src/routes/(app)/[instance]/search/+page.svelte b/src/routes/(app)/[instance]/search/+page.svelte index 4339d43..670beea 100644 --- a/src/routes/(app)/[instance]/search/+page.svelte +++ b/src/routes/(app)/[instance]/search/+page.svelte @@ -91,7 +91,6 @@ import Comment from '$lib/comments/Comment.svelte'; import CommunityCard from '$lib/CommunityCard.svelte'; import type { PageData } from './$types'; - import { parseISO } from 'date-fns'; import Title from '$lib/Title.svelte'; import VirtualFeed from '$lib/VirtualFeed.svelte'; import { feedLoader } from '$lib/post-loader'; @@ -112,7 +111,7 @@ personViewToContentView, postViewToContentView } from '$lib/content-views'; - import { navigateOnChange } from '$lib/utils'; + import { navigateOnChange, parseDate } from '$lib/utils'; import { profile } from '$lib/profiles/profiles'; export let data; @@ -232,8 +231,8 @@ users = data.users.map(personViewToContentView); return [...posts, ...comments, ...communities, ...users].sort((a, b) => { - const aPublished = parseISO(a.published + 'Z').getTime(), - bPublished = parseISO(b.published + 'Z').getTime(); + const aPublished = parseDate(a.published).getTime(), + bPublished = parseDate(b.published).getTime(); if (data.query.sort === 'New') { return bPublished - aPublished; diff --git a/src/routes/(meta)/about/+page.svelte b/src/routes/(meta)/about/+page.svelte index 8dde24b..243cf16 100644 --- a/src/routes/(meta)/about/+page.svelte +++ b/src/routes/(meta)/about/+page.svelte @@ -52,7 +52,8 @@

Version

- + + Alexandrite version {__ALEXANDRITE_VERSION__}

diff --git a/src/routes/style.scss b/src/routes/style.scss index 4e5c74a..646c1a2 100644 --- a/src/routes/style.scss +++ b/src/routes/style.scss @@ -9,3 +9,20 @@ font-size: var(--sx-font-size-2) !important; } } + +ul.hover-list { + display: flex; + flex-direction: column; + margin: 0; + + li { + display: flex; + align-items: center; + flex-grow: 1; + + &:hover { + background: var(--sx-gray-transparent); + border-radius: 5px; + } + } +}