diff --git a/src/api/admin/AdminStickerService.ts b/src/api/admin/AdminStickerService.ts index a756d57..bb65275 100644 --- a/src/api/admin/AdminStickerService.ts +++ b/src/api/admin/AdminStickerService.ts @@ -1,40 +1,9 @@ import { GET, Path, PathParam, POST, QueryParam } from "typescript-rest"; import { AdminService } from "./AdminService"; -import { Cache, CACHE_STICKERS } from "../../MemoryCache"; import StickerPack from "../../db/models/StickerPack"; -import Sticker from "../../db/models/Sticker"; import { ApiError } from "../ApiError"; - -export interface MemoryStickerPack { - id: number; - displayName: string; - avatarUrl: string; - description: string; - isEnabled: boolean; - author: { - type: string; - name: string; - reference: string; - }; - license: { - name: string; - urlPath: string; - }; - stickers: { - id: number; - name: string; - description: string; - image: { - mxc: string; - mimetype: string; - }; - thumbnail: { - mxc: string; - width: number; - height: number; - }; - }[]; -} +import { DimensionStickerService, MemoryStickerPack } from "../dimension/DimensionStickerService"; +import { Cache, CACHE_STICKERS } from "../../MemoryCache"; interface SetEnabledRequest { isEnabled: boolean; @@ -46,61 +15,11 @@ interface SetEnabledRequest { @Path("/api/v1/dimension/admin/stickers") export class AdminStickerService { - public static async getStickerPacks(enabledOnly: boolean = false): Promise { - const cachedPacks = Cache.for(CACHE_STICKERS).get("packs"); - if (cachedPacks) { - if (enabledOnly) return cachedPacks.filter(p => p.isEnabled); - return cachedPacks; - } - - const dbPacks = await StickerPack.findAll(); - const packs: MemoryStickerPack[] = []; - for (const pack of dbPacks) { - const stickers = await Sticker.findAll({where: {packId: pack.id}}); - packs.push({ - id: pack.id, - displayName: pack.name, - avatarUrl: pack.avatarUrl, - description: pack.description, - isEnabled: pack.isEnabled, - author: { - type: pack.authorType, - name: pack.authorName, - reference: pack.authorReference, - }, - license: { - name: pack.license, - urlPath: pack.licensePath, - }, - stickers: stickers.map(s => { - return { - id: s.id, - name: s.name, - description: s.description, - image: { - mxc: s.imageMxc, - mimetype: s.mimetype, - }, - thumbnail: { - mxc: s.thumbnailMxc, - width: s.thumbnailWidth, - height: s.thumbnailHeight, - }, - } - }), - }); - } - - Cache.for(CACHE_STICKERS).put("packs", packs); - if (enabledOnly) return packs.filter(p => p.isEnabled); - return packs; - } - @GET @Path("packs") public async getStickerPacks(@QueryParam("scalar_token") scalarToken: string): Promise { await AdminService.validateAndGetAdminTokenOwner(scalarToken); - return await AdminStickerService.getStickerPacks(); + return await DimensionStickerService.getStickerPacks(false); } @POST @@ -112,6 +31,7 @@ export class AdminStickerService { pack.isEnabled = request.isEnabled; await pack.save(); + Cache.for(CACHE_STICKERS).clear(); return {}; // 200 OK } diff --git a/src/api/dimension/DimensionStickerService.ts b/src/api/dimension/DimensionStickerService.ts new file mode 100644 index 0000000..aafb46f --- /dev/null +++ b/src/api/dimension/DimensionStickerService.ts @@ -0,0 +1,156 @@ +import { GET, Path, PathParam, POST, QueryParam } from "typescript-rest"; +import { Cache, CACHE_STICKERS } from "../../MemoryCache"; +import StickerPack from "../../db/models/StickerPack"; +import Sticker from "../../db/models/Sticker"; +import { ScalarService } from "../scalar/ScalarService"; +import UserStickerPack from "../../db/models/UserStickerPack"; +import { ApiError } from "../ApiError"; + +export interface MemoryStickerPack { + id: number; + displayName: string; + avatarUrl: string; + description: string; + isEnabled: boolean; + author: { + type: string; + name: string; + reference: string; + }; + license: { + name: string; + urlPath: string; + }; + stickers: { + id: number; + name: string; + description: string; + image: { + mxc: string; + mimetype: string; + }; + thumbnail: { + mxc: string; + width: number; + height: number; + }; + }[]; +} + +export interface MemoryUserStickerPack extends MemoryStickerPack { + isSelected: boolean; +} + +interface SetSelectedRequest { + isSelected: boolean; +} + +/** + * API for stickers + */ +@Path("/api/v1/dimension/stickers") +export class DimensionStickerService { + + public static async getStickerPacks(enabledOnly: boolean = false): Promise { + const cachedPacks = Cache.for(CACHE_STICKERS).get("packs"); + if (cachedPacks) { + if (enabledOnly) return cachedPacks.filter(p => p.isEnabled); + return cachedPacks; + } + + const dbPacks = await StickerPack.findAll(); + const packs: MemoryStickerPack[] = []; + for (const pack of dbPacks) { + packs.push(await DimensionStickerService.packToMemory(pack)); + } + + Cache.for(CACHE_STICKERS).put("packs", packs); + if (enabledOnly) return packs.filter(p => p.isEnabled); + return packs; + } + + @GET + @Path("packs") + public async getStickerPacks(@QueryParam("scalar_token") scalarToken: string): Promise { + const userId = await ScalarService.getTokenOwner(scalarToken); + + const cachedPacks = Cache.for(CACHE_STICKERS).get("packs_" + userId); + if (cachedPacks) return cachedPacks; + + const allPacks = await DimensionStickerService.getStickerPacks(true); + if (allPacks.length === 0) return []; // We can just skip the database call + + const userPacks = await UserStickerPack.findAll({where: {userId: userId, isSelected: true}}); + + const packs: MemoryUserStickerPack[] = []; + for (const pack of allPacks) { + const userPack = userPacks.find(p => p.packId === pack.id); + + const selectedPack = JSON.parse(JSON.stringify(pack)); + selectedPack.isSelected = userPack ? userPack.isSelected : false; + packs.push(selectedPack); + } + + Cache.for(CACHE_STICKERS).put("packs_" + userId, packs); + return packs; + } + + @POST + @Path("packs/:packId/selected") + public async setPackSelected(@QueryParam("scalar_token") scalarToken: string, @PathParam("packId") packId: number, request: SetSelectedRequest): Promise { + const userId = await ScalarService.getTokenOwner(scalarToken); + + const pack = await StickerPack.findByPrimary(packId); + if (!pack) throw new ApiError(404, "Sticker pack not found"); + + let userPack = await UserStickerPack.findOne({where: {userId: userId, packId: packId}}); + if (!userPack) { + userPack = await UserStickerPack.create({ + packId: packId, + userId: userId, + isSelected: false, + }); + } + + userPack.isSelected = request.isSelected; + await userPack.save(); + return {}; // 200 OK + } + + private static async packToMemory(pack: StickerPack): Promise { + const stickers = await Sticker.findAll({where: {packId: pack.id}}); + return { + id: pack.id, + displayName: pack.name, + avatarUrl: pack.avatarUrl, + description: pack.description, + isEnabled: pack.isEnabled, + author: { + type: pack.authorType, + name: pack.authorName, + reference: pack.authorReference, + }, + license: { + name: pack.license, + urlPath: pack.licensePath, + }, + stickers: stickers.map(s => { + return { + id: s.id, + name: s.name, + description: s.description, + image: { + mxc: s.imageMxc, + mimetype: s.mimetype, + }, + thumbnail: { + mxc: s.thumbnailMxc, + width: s.thumbnailWidth, + height: s.thumbnailHeight, + }, + } + }), + }; + } + +} \ No newline at end of file diff --git a/src/db/DimensionStore.ts b/src/db/DimensionStore.ts index 7f78d12..eb55920 100644 --- a/src/db/DimensionStore.ts +++ b/src/db/DimensionStore.ts @@ -20,6 +20,7 @@ import IrcBridgeRecord from "./models/IrcBridgeRecord"; import IrcBridgeNetwork from "./models/IrcBridgeNetwork"; import StickerPack from "./models/StickerPack"; import Sticker from "./models/Sticker"; +import UserStickerPack from "./models/UserStickerPack"; class _DimensionStore { private sequelize: Sequelize; @@ -51,6 +52,7 @@ class _DimensionStore { IrcBridgeNetwork, StickerPack, Sticker, + UserStickerPack, ]); } diff --git a/src/db/migrations/20180512232045-AddUserStickerPacks.ts b/src/db/migrations/20180512232045-AddUserStickerPacks.ts new file mode 100644 index 0000000..1b7c582 --- /dev/null +++ b/src/db/migrations/20180512232045-AddUserStickerPacks.ts @@ -0,0 +1,24 @@ +import { QueryInterface } from "sequelize"; +import { DataType } from "sequelize-typescript"; + +export default { + up: (queryInterface: QueryInterface) => { + return queryInterface.createTable("dimension_user_sticker_packs", { + "id": {type: DataType.INTEGER, primaryKey: true, autoIncrement: true, allowNull: false}, + "packId": { + type: DataType.INTEGER, allowNull: false, + references: {model: "dimension_sticker_packs", key: "id"}, + onUpdate: "cascade", onDelete: "cascade", + }, + "userId": { + type: DataType.STRING, allowNull: false, + references: {model: "dimension_users", key: "userId"}, + onUpdate: "cascade", onDelete: "cascade", + }, + "isSelected": {type: DataType.BOOLEAN, allowNull: false}, + }) + }, + down: (queryInterface: QueryInterface) => { + return queryInterface.dropTable("dimension_user_sticker_packs"); + } +} \ No newline at end of file diff --git a/src/db/models/UserStickerPack.ts b/src/db/models/UserStickerPack.ts new file mode 100644 index 0000000..5754964 --- /dev/null +++ b/src/db/models/UserStickerPack.ts @@ -0,0 +1,26 @@ +import { AutoIncrement, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript"; +import StickerPack from "./StickerPack"; +import User from "./User"; + +@Table({ + tableName: "dimension_user_sticker_packs", + underscoredAll: false, + timestamps: false, +}) +export default class UserStickerPack extends Model { + @PrimaryKey + @AutoIncrement + @Column + id: number; + + @Column + @ForeignKey(() => StickerPack) + packId: number; + + @Column + @ForeignKey(() => User) + userId: string; + + @Column + isSelected: boolean; +} \ No newline at end of file diff --git a/web/app/app.module.ts b/web/app/app.module.ts index 4434af9..6dac63c 100644 --- a/web/app/app.module.ts +++ b/web/app/app.module.ts @@ -75,6 +75,8 @@ import { AdminStickersApiService } from "./shared/services/admin/admin-stickers- import { AdminStickerPacksComponent } from "./admin/sticker-packs/sticker-packs.component"; import { AdminStickerPackPreviewComponent } from "./admin/sticker-packs/preview/preview.component"; import { MediaService } from "./shared/services/media.service"; +import { StickerApiService } from "./shared/services/integrations/sticker-api.service"; +import { StickerpickerComponent } from "./configs/stickerpicker/stickerpicker.component"; @NgModule({ imports: [ @@ -140,6 +142,7 @@ import { MediaService } from "./shared/services/media.service"; ScreenshotCapableDirective, AdminStickerPacksComponent, AdminStickerPackPreviewComponent, + StickerpickerComponent, // Vendor ], @@ -158,6 +161,7 @@ import { MediaService } from "./shared/services/media.service"; IrcApiService, AdminStickersApiService, MediaService, + StickerApiService, {provide: Window, useValue: window}, // Vendor diff --git a/web/app/app.routing.ts b/web/app/app.routing.ts index da56e32..4c86546 100644 --- a/web/app/app.routing.ts +++ b/web/app/app.routing.ts @@ -25,6 +25,7 @@ import { AdminBridgesComponent } from "./admin/bridges/bridges.component"; import { AdminIrcBridgeComponent } from "./admin/bridges/irc/irc.component"; import { IrcBridgeConfigComponent } from "./configs/bridge/irc/irc.bridge.component"; import { AdminStickerPacksComponent } from "./admin/sticker-packs/sticker-packs.component"; +import { StickerpickerComponent } from "./configs/stickerpicker/stickerpicker.component"; const routes: Routes = [ {path: "", component: HomeComponent}, @@ -164,6 +165,11 @@ const routes: Routes = [ }, ], }, + { + path: "stickerpicker", + component: StickerpickerComponent, + data: {breadcrumb: "Your Sticker Packs", name: "Your Sticker Packs"}, + }, ], }, { diff --git a/web/app/configs/stickerpicker/stickerpicker.component.html b/web/app/configs/stickerpicker/stickerpicker.component.html new file mode 100644 index 0000000..f515e84 --- /dev/null +++ b/web/app/configs/stickerpicker/stickerpicker.component.html @@ -0,0 +1,25 @@ +
+ +
+
+ +
+
Sticker packs are not enabled on this Dimension instance.
+
+
+
+ +
+ + + {{ pack.displayName }} + {{ pack.description }} + + Created by {{ pack.author.name }} under + {{ pack.license.name }} +
+
+
+
+
\ No newline at end of file diff --git a/web/app/configs/stickerpicker/stickerpicker.component.scss b/web/app/configs/stickerpicker/stickerpicker.component.scss new file mode 100644 index 0000000..36ee5f5 --- /dev/null +++ b/web/app/configs/stickerpicker/stickerpicker.component.scss @@ -0,0 +1,38 @@ +.pack { + display: flex; + margin: 20px; + padding: 5px; + background-color: #f6fbff; + + .caption { + flex: 1; + margin-left: 20px; + padding-top: 20px; + + .name { + font-size: 1.1em; + font-weight: bold; + display: block; + } + + .description { + color: #7d7d7d; + display: block; + } + + .toggle-switch { + margin-top: 20px; + margin-right: 10px; + float: right; + } + + .author, .license { + font-size: 0.8em; + color: #7d7d7d; + + a { + color: #7d7d7d; + } + } + } +} \ No newline at end of file diff --git a/web/app/configs/stickerpicker/stickerpicker.component.ts b/web/app/configs/stickerpicker/stickerpicker.component.ts new file mode 100644 index 0000000..9a04568 --- /dev/null +++ b/web/app/configs/stickerpicker/stickerpicker.component.ts @@ -0,0 +1,52 @@ +import { Component, OnInit } from "@angular/core"; +import { FE_UserStickerPack } from "../../shared/models/integration"; +import { StickerApiService } from "../../shared/services/integrations/sticker-api.service"; +import { ToasterService } from "angular2-toaster"; +import { MediaService } from "../../shared/services/media.service"; + +@Component({ + templateUrl: "stickerpicker.component.html", + styleUrls: ["stickerpicker.component.scss"], +}) +export class StickerpickerComponent implements OnInit { + + public isLoading = true; + public isUpdating = false; + public packs: FE_UserStickerPack[]; + + constructor(private stickerApi: StickerApiService, + private media: MediaService, + private toaster: ToasterService) { + this.isLoading = true; + this.isUpdating = false; + } + + public async ngOnInit() { + try { + this.packs = await this.stickerApi.getPacks(); + this.isLoading = false; + } catch (e) { + console.error(e); + this.toaster.pop("error", "Failed to load sticker packs"); + } + } + + public getThumbnailUrl(mxc: string, width: number, height: number, method: "crop" | "scale" = "scale"): string { + return this.media.getThumbnailUrl(mxc, width, height, method, true); + } + + public toggleSelected(pack: FE_UserStickerPack) { + pack.isSelected = !pack.isSelected; + this.isUpdating = true; + this.stickerApi.togglePackSelection(pack.id, pack.isSelected).then(() => { + this.isUpdating = false; + this.toaster.pop("success", "Stickers updated"); + // TODO: Add the user widget when we have >1 sticker pack selected + }).catch(err => { + console.error(err); + pack.isSelected = !pack.isSelected; // revert change + this.isUpdating = false; + this.toaster.pop("error", "Error updating stickers"); + }); + } +} \ No newline at end of file diff --git a/web/app/riot/riot-home/home.component.ts b/web/app/riot/riot-home/home.component.ts index a6c5637..795b100 100644 --- a/web/app/riot/riot-home/home.component.ts +++ b/web/app/riot/riot-home/home.component.ts @@ -195,6 +195,12 @@ export class RiotHomeComponent { let type = null; if (!this.requestedScreen) return; + if (this.requestedScreen === "type_m.stickerpicker") { + console.log("Intercepting config screen handling to open sticker picker config"); + this.router.navigate(['riot-app', 'stickerpicker']); + return; + } + const targetIntegration = IntegrationsRegistry.getIntegrationForScreen(this.requestedScreen); if (targetIntegration) { category = targetIntegration.category; diff --git a/web/app/shared/models/integration.ts b/web/app/shared/models/integration.ts index 9d6f357..cc3fcb1 100644 --- a/web/app/shared/models/integration.ts +++ b/web/app/shared/models/integration.ts @@ -44,6 +44,10 @@ export interface FE_StickerPack extends FE_Integration { stickers: FE_Sticker[]; } +export interface FE_UserStickerPack extends FE_StickerPack { + isSelected: boolean; +} + export interface FE_Sticker { id: number; name: string; diff --git a/web/app/shared/services/integrations/sticker-api.service.ts b/web/app/shared/services/integrations/sticker-api.service.ts new file mode 100644 index 0000000..1ac3431 --- /dev/null +++ b/web/app/shared/services/integrations/sticker-api.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from "@angular/core"; +import { Http } from "@angular/http"; +import { AuthedApi } from "../authed-api"; +import { FE_UserStickerPack } from "../../models/integration"; + +@Injectable() +export class StickerApiService extends AuthedApi { + constructor(http: Http) { + super(http); + } + + public getPacks(): Promise { + return this.authedGet("/api/v1/dimension/stickers/packs").map(r => r.json()).toPromise(); + } + + public togglePackSelection(packId: number, isSelected: boolean): Promise { + return this.authedPost("/api/v1/dimension/stickers/packs/" + packId + "/selected", {isSelected: isSelected}).map(r => r.json()).toPromise(); + } +}