ADD - implement Weazel News app

This commit is contained in:
Dominik
2026-08-13 00:03:25 +02:00
parent b9b044832d
commit ce6393ba07
19 changed files with 4255 additions and 0 deletions
@@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Weazel News">
<defs>
<linearGradient id="weazel-news-background" x1="18" y1="10" x2="112" y2="122" gradientUnits="userSpaceOnUse">
<stop stop-color="#171717"/>
<stop offset="1" stop-color="#050505"/>
</linearGradient>
<linearGradient id="weazel-news-accent" x1="26" y1="24" x2="103" y2="105" gradientUnits="userSpaceOnUse">
<stop stop-color="#ff4a50"/>
<stop offset="1" stop-color="#c9101c"/>
</linearGradient>
</defs>
<rect width="128" height="128" rx="28" fill="url(#weazel-news-background)"/>
<path d="M28 31h72a8 8 0 0 1 8 8v58a8 8 0 0 1-8 8H28a8 8 0 0 1-8-8V39a8 8 0 0 1 8-8Z" fill="#f4f4f1"/>
<path d="M20 46h88v-7a8 8 0 0 0-8-8H28a8 8 0 0 0-8 8v7Z" fill="url(#weazel-news-accent)"/>
<path d="M31 58h34v26H31z" fill="#181818"/>
<path d="M72 59h25M72 68h25M72 77h20M31 92h66" fill="none" stroke="#747474" stroke-width="5" stroke-linecap="round"/>
<path d="m38 63 6 16 5-12 5 12 6-16" fill="none" stroke="#f4f4f1" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="96" cy="38" r="3" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+14
View File
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Newspaper } from 'lucide-vue-next'
import { isPhoneAppId, PHONE_APPS } from './apps'
describe('app registry', () => {
it('has unique ids and routes with the reference dock order', () => {
@@ -58,6 +60,16 @@ describe('app registry', () => {
labelKey: 'Apps.companies.name',
route: '/apps/companies',
})
expect(PHONE_APPS.find((app) => app.id === 'weazel-news')).toMatchObject({
category: 'social',
dockOrder: null,
gridOrder: 29,
labelKey: 'Apps.weazelNews.name',
route: '/apps/weazel-news',
})
expect(PHONE_APPS.find((app) => app.id === 'weazel-news')?.icon).toBe(
Newspaper,
)
expect(PHONE_APPS.find((app) => app.id === 'music')).toMatchObject({
category: 'utilities',
gridOrder: 26,
@@ -135,6 +147,7 @@ describe('app registry', () => {
expect(isPhoneAppId('skyride')).toBe(true)
expect(isPhoneAppId('music')).toBe(true)
expect(isPhoneAppId('companies')).toBe(true)
expect(isPhoneAppId('weazel-news')).toBe(true)
expect(
PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id),
).toEqual([
@@ -151,6 +164,7 @@ describe('app registry', () => {
(app) => app.id,
),
).toEqual([
'weazel-news',
'picstagram',
'feather',
'fliptok',
+16
View File
@@ -33,6 +33,7 @@ import {
ReceiptText,
UsersRound,
Building2,
Newspaper,
} from 'lucide-vue-next'
import { defineAsyncComponent, markRaw, shallowReactive } from 'vue'
@@ -72,6 +73,7 @@ import musicIcon from '@/assets/img/app-icons/music.svg'
import featherIcon from '@/assets/img/app-icons/feather.svg'
import crewLinkIcon from '@/assets/img/app-icons/crewlink.svg'
import companiesIcon from '@/assets/img/app-icons/companies.svg'
import weazelNewsIcon from '@/assets/img/app-icons/weazel-news.svg'
import type {
BuiltinPhoneAppDefinition,
BuiltinPhoneAppId,
@@ -83,6 +85,20 @@ import type {
} from '@/types/apps'
export const PHONE_APPS = shallowReactive<PhoneAppDefinition[]>([
{
category: 'social',
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/weazel-news-app.vue')),
),
dockOrder: null,
gridOrder: 29,
icon: markRaw(Newspaper),
iconClass: 'app-icon--weazel-news',
iconImage: weazelNewsIcon,
id: 'weazel-news',
labelKey: 'Apps.weazelNews.name',
route: '/apps/weazel-news',
},
{
category: 'utilities',
component: markRaw(
+130
View File
@@ -414,6 +414,136 @@ const defaultLocales: LocaleTree = {
request_failed: 'EasyShare is temporarily unavailable.',
},
},
weazelNews: {
name: 'Weazel News',
brand: 'WEAZEL NEWS',
navigation: 'Weazel News navigation',
tabs: {
home: 'Home',
categories: 'Categories',
search: 'Search',
editorial: 'Editorial',
},
back: 'Back',
retry: 'Try Again',
loadMore: 'Load More',
home: {
eyebrow: 'Los Santos, live',
latest: 'Latest News',
},
categories: {
title: 'Categories',
subtitle: 'Browse every story from the Weazel News desk.',
all: 'All Stories',
official: 'Official Notices',
events: 'Events & Leisure',
jobs: 'Jobs',
news: 'News & Events',
business: 'Business',
},
search: {
placeholder: 'Search articles...',
title: 'Search Weazel News',
emptyTitle: 'No Articles Found',
emptyBody: 'Try another headline, topic, or category.',
},
states: {
loading: 'Loading Weazel News...',
emptyTitle: 'No News Yet',
emptyBody:
'The Weazel News desk has not published any articles here yet.',
errorTitle: 'Weazel News Is Unavailable',
readOnlyTitle: 'Read-only Access',
readOnlyBody:
'Your current job can read Weazel News, but cannot manage articles.',
noManagedTitle: 'No Editorial Articles',
noManagedBody:
'Create an article or change the current editorial filter.',
},
article: {
readMore: 'Read Article',
byline: 'By {author}',
updated: 'Updated {date}',
published: 'Published {date}',
coverAlt: 'Cover image for {title}',
},
editorial: {
title: 'Editorial Desk',
subtitle: 'Create, publish, and maintain Weazel News articles.',
newArticle: 'New Article',
published: 'Published',
drafts: 'Drafts',
all: 'All',
jobAccess: 'Signed in as {job}',
edit: 'Edit Article',
delete: 'Delete Article',
},
composer: {
newTitle: 'New Article',
editTitle: 'Edit Article',
title: 'Headline',
titlePlaceholder: 'Write a clear headline',
body: 'Article',
bodyPlaceholder: 'Write the full story...',
category: 'Category',
status: 'Publication Status',
cover: 'Cover Image',
chooseCover: 'Choose from Gallery',
changeCover: 'Change Cover',
removeCover: 'Remove Cover',
coverAlt: 'Selected article cover',
publish: 'Publish Article',
saveDraft: 'Save Draft',
saveChanges: 'Save Changes',
statusPublished: 'Published',
statusDraft: 'Draft',
},
delete: {
title: 'Delete Article?',
body: 'This article will be removed from Weazel News.',
cancel: 'Cancel',
confirm: 'Delete',
},
feedback: {
created: 'Article created.',
updated: 'Article updated.',
deleted: 'Article deleted.',
},
accessibility: {
articleList: 'Weazel News articles',
categoryList: 'News categories',
editorialList: 'Editorial articles',
openArticle: 'Open {title}',
openCategory: 'Open {category}',
openEditorial: 'Open the editorial desk',
newArticle: 'Create a new article',
editArticle: 'Edit {title}',
deleteArticle: 'Delete {title}',
search: 'Search Weazel News',
clearSearch: 'Clear article search',
coverPreview: 'Article cover preview',
removeCover: 'Remove the selected cover image',
status: 'Article status: {status}',
},
errors: {
feature_disabled: 'Weazel News is currently disabled.',
invalid_article: 'Enter a valid headline, article, and category.',
invalid_draft:
'Enter a headline and article text before saving the draft.',
invalid_publish: 'Enter a headline and article text before publishing.',
invalid_request: 'Check the article details and try again.',
invalid_image: 'Choose a valid photo from this phone.',
invalid_attachment: 'Choose a valid photo from this phone.',
not_authorized: 'Your current job cannot manage Weazel News articles.',
article_not_found: 'This article is no longer available.',
not_found: 'This article is no longer available.',
revision_conflict:
'This article changed on another device. Reload it and try again.',
rate_limited: 'Too many requests. Try again shortly.',
request_failed: 'Weazel News could not complete the request.',
default: 'Weazel News is temporarily unavailable.',
},
},
companies: companiesFallbackLocales,
crewlink: {
name: 'CrewLink',
+212
View File
@@ -0,0 +1,212 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWeazelNewsStore } from '@/stores/weazel-news'
import type {
WeazelNewsArticle,
WeazelNewsArticleDraft,
} from '@/types/weazel-news'
import { nuiCall } from '@/utils/nui'
vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() }))
const mockNuiCall = vi.mocked(nuiCall)
const article: WeazelNewsArticle = {
authorName: 'Jane Doe',
body: 'The full article body.',
category: 'news',
createdAt: 1_754_000_000,
excerpt: 'The article excerpt.',
id: '7d28b252-0532-4869-9512-e313e34d2fb8',
imageMediaId: 12,
imageUrl: 'https://media.invalid/weazel/article.webp',
publishedAt: 1_754_000_100,
revision: 4,
status: 'published',
title: 'Breaking news',
updatedAt: 1_754_000_100,
}
const draft: WeazelNewsArticleDraft = {
body: 'Updated body.',
category: 'business',
imageMediaId: null,
status: 'draft',
title: 'Updated headline',
}
describe('Weazel News store', () => {
beforeEach(() => {
setActivePinia(createPinia())
mockNuiCall.mockReset()
})
it('loads the server-owned management context and category counts', async () => {
mockNuiCall.mockResolvedValueOnce({
data: {
canManage: true,
categories: [
{ count: 3, id: 'news' },
{ count: 1, id: 'jobs' },
],
jobGradeLabel: 'Editor',
jobLabel: 'Weazel News',
},
success: true,
})
const store = useWeazelNewsStore()
expect(await store.loadContext()).toBe(true)
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:context')
expect(store.context?.canManage).toBe(true)
expect(store.context?.categories[0]).toEqual({ count: 3, id: 'news' })
})
it('loads and deduplicates appended public pages', async () => {
mockNuiCall
.mockResolvedValueOnce({
data: { hasMore: true, items: [article] },
success: true,
})
.mockResolvedValueOnce({
data: {
hasMore: false,
items: [
{ ...article, revision: 5, title: 'Canonical headline' },
{ ...article, id: 'second-article' },
],
},
success: true,
})
const store = useWeazelNewsStore()
await store.loadPublic({ category: 'news', search: 'breaking' })
await store.loadPublic({
append: true,
category: 'news',
search: 'breaking',
})
expect(mockNuiCall).toHaveBeenNthCalledWith(1, 'weazel-news:list', {
category: 'news',
offset: 0,
search: 'breaking',
})
expect(mockNuiCall).toHaveBeenNthCalledWith(2, 'weazel-news:list', {
category: 'news',
offset: 1,
search: 'breaking',
})
expect(store.publicItems).toHaveLength(2)
expect(store.publicItems[0]?.title).toBe('Canonical headline')
expect(store.publicHasMore).toBe(false)
})
it('loads paged management results including drafts', async () => {
mockNuiCall.mockResolvedValueOnce({
data: { hasMore: false, items: [{ ...article, status: 'draft' }] },
success: true,
})
const store = useWeazelNewsStore()
expect(
await store.loadManaged('draft', { offset: 20, search: 'city' }),
).toBe(true)
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:manage-list', {
offset: 20,
search: 'city',
status: 'draft',
})
expect(store.managedItems[0]?.status).toBe('draft')
})
it('loads a canonical article for public or management detail', async () => {
mockNuiCall.mockResolvedValueOnce({
data: { article },
success: true,
})
const store = useWeazelNewsStore()
expect(await store.loadArticle(article.id, true)).toBe(true)
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:get', {
id: article.id,
manage: true,
})
expect(store.selected).toEqual(article)
})
it('normalizes a direct canonical create response', async () => {
const created = { ...article, id: 'created-article', revision: 1 }
mockNuiCall.mockResolvedValueOnce({ data: created, success: true })
const store = useWeazelNewsStore()
expect(await store.create({ ...draft, status: 'published' })).toEqual(
created,
)
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:create', {
...draft,
status: 'published',
})
expect(store.managedItems).toEqual([created])
expect(store.publicItems).toEqual([created])
})
it('sends the optimistic revision and applies the canonical update', async () => {
const canonical = {
...article,
...draft,
excerpt: 'Updated excerpt.',
revision: 5,
updatedAt: 1_754_100_000,
}
mockNuiCall.mockResolvedValueOnce({
data: { article: canonical },
success: true,
})
const store = useWeazelNewsStore()
store.publicItems = [article]
store.managedItems = [article]
store.selected = article
expect(await store.update(article, draft)).toEqual(canonical)
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:update', {
...draft,
id: article.id,
revision: 4,
})
expect(store.selected).toEqual(canonical)
expect(store.managedItems).toEqual([canonical])
expect(store.publicItems).toEqual([])
})
it('keeps local articles unchanged after a revision conflict', async () => {
mockNuiCall.mockResolvedValueOnce({
error: 'revision_conflict',
success: false,
})
const store = useWeazelNewsStore()
store.managedItems = [article]
expect(await store.update(article, draft)).toBeNull()
expect(store.managedItems).toEqual([article])
expect(store.error).toBe('revision_conflict')
})
it('deletes by id and revision only after server confirmation', async () => {
mockNuiCall.mockResolvedValueOnce({ success: true })
const store = useWeazelNewsStore()
store.publicItems = [article]
store.managedItems = [article]
store.selected = article
expect(await store.remove(article)).toBe(true)
expect(mockNuiCall).toHaveBeenCalledWith('weazel-news:delete', {
id: article.id,
revision: article.revision,
})
expect(store.publicItems).toEqual([])
expect(store.managedItems).toEqual([])
expect(store.selected).toBeNull()
})
})
+283
View File
@@ -0,0 +1,283 @@
import { defineStore } from 'pinia'
import type {
WeazelNewsArticle,
WeazelNewsArticleDraft,
WeazelNewsArticleResponse,
WeazelNewsArticleSummary,
WeazelNewsContext,
WeazelNewsListResponse,
WeazelNewsManagedListOptions,
WeazelNewsManageStatus,
WeazelNewsPublicListOptions,
} from '@/types/weazel-news'
import { nuiCall } from '@/utils/nui'
type ArticleMutationResponse = WeazelNewsArticle | WeazelNewsArticleResponse
function readArticle(
data: ArticleMutationResponse | undefined,
): WeazelNewsArticle | null {
if (!data) return null
return 'article' in data ? data.article : data
}
function replaceArticle(
items: WeazelNewsArticleSummary[],
article: WeazelNewsArticleSummary,
): WeazelNewsArticleSummary[] {
return items.map((item) => (item.id === article.id ? article : item))
}
function mergeArticles(
current: WeazelNewsArticleSummary[],
incoming: WeazelNewsArticleSummary[],
): WeazelNewsArticleSummary[] {
const merged = new Map(current.map((article) => [article.id, article]))
for (const article of incoming) merged.set(article.id, article)
return [...merged.values()]
}
export const useWeazelNewsStore = defineStore('weazel-news', {
state: () => ({
context: null as WeazelNewsContext | null,
contextError: '',
contextLoadSequence: 0,
contextLoading: false,
detailError: '',
detailLoadSequence: 0,
detailLoading: false,
error: '',
loading: false,
managedError: '',
managedLoadSequence: 0,
managedLoading: false,
managedHasMore: false,
managedItems: [] as WeazelNewsArticleSummary[],
mutating: false,
publicHasMore: false,
publicError: '',
publicLoadSequence: 0,
publicLoading: false,
publicItems: [] as WeazelNewsArticleSummary[],
selected: null as WeazelNewsArticle | null,
}),
actions: {
async loadContext(): Promise<boolean> {
const requestSequence = ++this.contextLoadSequence
this.contextLoading = true
this.loading = true
const response = await nuiCall<WeazelNewsContext>('weazel-news:context')
if (requestSequence !== this.contextLoadSequence) return false
this.contextLoading = false
this.loading =
this.publicLoading || this.managedLoading || this.detailLoading
if (!response.success || !response.data) {
this.contextError = response.error ?? 'request_failed'
this.error = this.contextError
return false
}
this.context = response.data
this.contextError = ''
this.error = ''
return true
},
async loadPublic(
options: WeazelNewsPublicListOptions = {},
): Promise<boolean> {
const append = options.append ?? false
const requestSequence = ++this.publicLoadSequence
this.publicLoading = true
this.loading = true
const response = await nuiCall<WeazelNewsListResponse>(
'weazel-news:list',
{
category: options.category ?? null,
offset: options.offset ?? (append ? this.publicItems.length : 0),
search: options.search ?? '',
},
)
if (requestSequence !== this.publicLoadSequence) return false
this.publicLoading = false
this.loading =
this.contextLoading || this.managedLoading || this.detailLoading
if (!response.success || !response.data) {
this.publicError = response.error ?? 'request_failed'
this.error = this.publicError
return false
}
this.publicItems = append
? mergeArticles(this.publicItems, response.data.items)
: response.data.items
this.publicHasMore = response.data.hasMore
this.publicError = ''
this.error = ''
return true
},
async loadManaged(
status: WeazelNewsManageStatus,
options: WeazelNewsManagedListOptions = {},
): Promise<boolean> {
const append = options.append ?? false
const requestSequence = ++this.managedLoadSequence
this.managedLoading = true
this.loading = true
const response = await nuiCall<WeazelNewsListResponse>(
'weazel-news:manage-list',
{
offset: options.offset ?? (append ? this.managedItems.length : 0),
search: options.search ?? '',
status,
},
)
if (requestSequence !== this.managedLoadSequence) return false
this.managedLoading = false
this.loading =
this.contextLoading || this.publicLoading || this.detailLoading
if (!response.success || !response.data) {
this.managedError = response.error ?? 'request_failed'
this.error = this.managedError
return false
}
this.managedItems = append
? mergeArticles(this.managedItems, response.data.items)
: response.data.items
this.managedHasMore = response.data.hasMore
this.managedError = ''
this.error = ''
return true
},
async loadArticle(id: string, manage = false): Promise<boolean> {
const requestSequence = ++this.detailLoadSequence
this.detailLoading = true
this.loading = true
const response = await nuiCall<ArticleMutationResponse>(
'weazel-news:get',
{
id,
manage,
},
)
if (requestSequence !== this.detailLoadSequence) return false
this.detailLoading = false
this.loading =
this.contextLoading || this.publicLoading || this.managedLoading
const article = response.success ? readArticle(response.data) : null
if (!article) {
this.detailError = response.error ?? 'request_failed'
this.error = this.detailError
return false
}
this.selected = article
this.detailError = ''
this.publicItems = replaceArticle(this.publicItems, article)
this.managedItems = replaceArticle(this.managedItems, article)
this.error = ''
return true
},
async create(
draft: WeazelNewsArticleDraft,
): Promise<WeazelNewsArticle | null> {
this.publicLoadSequence += 1
this.managedLoadSequence += 1
this.detailLoadSequence += 1
this.publicLoading = false
this.managedLoading = false
this.detailLoading = false
this.loading = this.contextLoading
this.mutating = true
const response = await nuiCall<ArticleMutationResponse>(
'weazel-news:create',
draft,
)
this.mutating = false
const article = response.success ? readArticle(response.data) : null
if (!article) {
this.error = response.error ?? 'request_failed'
return null
}
this.selected = article
this.managedItems = [
article,
...this.managedItems.filter((item) => item.id !== article.id),
]
if (article.status === 'published') {
this.publicItems = [
article,
...this.publicItems.filter((item) => item.id !== article.id),
]
}
this.error = ''
return article
},
async update(
article: WeazelNewsArticle,
draft: WeazelNewsArticleDraft,
): Promise<WeazelNewsArticle | null> {
this.publicLoadSequence += 1
this.managedLoadSequence += 1
this.detailLoadSequence += 1
this.publicLoading = false
this.managedLoading = false
this.detailLoading = false
this.loading = this.contextLoading
this.mutating = true
const response = await nuiCall<ArticleMutationResponse>(
'weazel-news:update',
{
...draft,
id: article.id,
revision: article.revision,
},
)
this.mutating = false
const canonical = response.success ? readArticle(response.data) : null
if (!canonical) {
this.error = response.error ?? 'request_failed'
return null
}
if (this.selected?.id === canonical.id) this.selected = canonical
this.managedItems = replaceArticle(this.managedItems, canonical)
this.publicItems =
canonical.status === 'published'
? [
canonical,
...this.publicItems.filter((item) => item.id !== canonical.id),
].sort(
(left, right) =>
(right.publishedAt ?? 0) - (left.publishedAt ?? 0),
)
: this.publicItems.filter((item) => item.id !== canonical.id)
this.error = ''
return canonical
},
async remove(article: WeazelNewsArticle): Promise<boolean> {
this.publicLoadSequence += 1
this.managedLoadSequence += 1
this.detailLoadSequence += 1
this.publicLoading = false
this.managedLoading = false
this.detailLoading = false
this.loading = this.contextLoading
this.mutating = true
const response = await nuiCall('weazel-news:delete', {
id: article.id,
revision: article.revision,
})
this.mutating = false
if (!response.success) {
this.error = response.error ?? 'request_failed'
return false
}
this.publicItems = this.publicItems.filter(
(item) => item.id !== article.id,
)
this.managedItems = this.managedItems.filter(
(item) => item.id !== article.id,
)
if (this.selected?.id === article.id) this.selected = null
this.error = ''
return true
},
},
})
+1
View File
@@ -37,6 +37,7 @@ export type BuiltinPhoneAppId =
| 'feather'
| 'crewlink'
| 'companies'
| 'weazel-news'
declare const externalPhoneAppId: unique symbol
+76
View File
@@ -0,0 +1,76 @@
export const WEAZEL_NEWS_CATEGORY_IDS = [
'official',
'events',
'jobs',
'news',
'business',
] as const
export const WEAZEL_NEWS_ARTICLE_STATUSES = ['draft', 'published'] as const
export type WeazelNewsCategoryId = (typeof WEAZEL_NEWS_CATEGORY_IDS)[number]
export type WeazelNewsArticleStatus =
(typeof WEAZEL_NEWS_ARTICLE_STATUSES)[number]
export type WeazelNewsManageStatus = 'all' | WeazelNewsArticleStatus
export type WeazelNewsCategorySummary = {
count: number
id: WeazelNewsCategoryId
}
export type WeazelNewsArticle = {
authorName: string
body: string
category: WeazelNewsCategoryId
createdAt: number
excerpt: string
id: string
imageMediaId?: number | null
imageUrl?: string | null
publishedAt?: number | null
revision: number
status: WeazelNewsArticleStatus
title: string
updatedAt: number
}
export type WeazelNewsArticleSummary = Omit<WeazelNewsArticle, 'body'>
export type WeazelNewsArticleDraft = {
body: string
category: WeazelNewsCategoryId
imageMediaId: number | null
status: WeazelNewsArticleStatus
title: string
}
export type WeazelNewsContext = {
canManage: boolean
categories: WeazelNewsCategorySummary[]
jobGradeLabel?: string
jobLabel?: string
}
export type WeazelNewsListResponse = {
hasMore: boolean
items: WeazelNewsArticleSummary[]
}
export type WeazelNewsPublicListOptions = {
append?: boolean
category?: WeazelNewsCategoryId | null
offset?: number
search?: string
}
export type WeazelNewsManagedListOptions = {
append?: boolean
offset?: number
search?: string
}
export type WeazelNewsArticleResponse = {
article: WeazelNewsArticle
}
+1
View File
@@ -87,6 +87,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
'neon-drop': { enabled: true, sounds: true },
citymarkt: { enabled: true, sounds: true },
companies: { enabled: true, sounds: true },
'weazel-news': { enabled: true, sounds: true },
'local-pages': { enabled: true, sounds: true },
picstagram: { enabled: true, sounds: true },
fliptok: { enabled: true, sounds: true },
File diff suppressed because it is too large Load Diff